From 8ac5043f6c76976af04936d3b50bebfbf941ee02 Mon Sep 17 00:00:00 2001 From: w-grok-bot-onboarding Date: Tue, 8 Sep 2026 08:25:26 -0700 Subject: [PATCH 1/5] cli: `maxplayer skill` + the docs pointer on --help, doctor and seller first-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on ec95eb2: the whole binary carried exactly one route to the docs, the MCP initialize.instructions text in mcp.rs (mcp.rs:31, tests at :701/:714). A buyer that registers `maxplayer mcp` was told where the guides are; nobody else ever was — not `maxplayer --help`, not `maxplayer doctor`, not a seller configuring a new seat — and mcp.rs:47 records that a client may discard `instructions`. A seller-only box never touches MCP, so it had no route at all. - New `crates/maxplayer/src/skill.rs`: `SKILL_URL`, `SKILL_INDEX_URL`, the shared `docs_pointer_line()`, and `maxplayer skill`, which prints the orientation URL and the machine-readable index to stdout. Pure: no home, key, wallet or network. - The same one-line pointer on the three paths that carried none: the top-level usage (stdout on --help, stderr on a wrong invocation), the doctor report preamble and `doctor --help`, and the seller first-run path after `wrote [seller]` (new seat only; relaunches stay quiet) plus `seller --help`. - mcp.rs INSTRUCTION_GUIDES now formats from the shared constant instead of carrying a second copy of the URL. Handshake text unchanged; the exact-text tests still pass. - Tests assert against the shared constant, never a hand-copied URL: skill::tests (4), cli::tests::help_and_skill_carry_the_docs_pointer, doctor::tests::doctor_usage_and_report_preamble_carry_the_docs_pointer, sell::tests::first_run_writes_seller_and_points_at_the_docs_once, and `skill --help` joins the #570 every-subcommand help roll-call. --- crates/maxplayer/src/cli.rs | 44 +++++++++- crates/maxplayer/src/doctor.rs | 44 +++++++++- crates/maxplayer/src/main.rs | 4 + crates/maxplayer/src/mcp.rs | 15 +++- crates/maxplayer/src/sell.rs | 69 +++++++++++++++- crates/maxplayer/src/skill.rs | 144 +++++++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+), 7 deletions(-) create mode 100644 crates/maxplayer/src/skill.rs diff --git a/crates/maxplayer/src/cli.rs b/crates/maxplayer/src/cli.rs index 4264b3a66..3bd210e78 100644 --- a/crates/maxplayer/src/cli.rs +++ b/crates/maxplayer/src/cli.rs @@ -68,6 +68,9 @@ where Some("wallet") => crate::wallet_cli::run(&args[2..], out, err), Some("profile") => crate::profile_cli::run(&args[2..], out, err), Some("whoami") => crate::whoami::run(&args[2..], out, err), + // Where the agent documentation lives. Pure print — no home, key, wallet or network — so + // it works on a box that has installed nothing but the binary. + Some("skill") => crate::skill::run(&args[2..], out, err), #[cfg(feature = "stub-pay")] Some("stub-pay") => crate::stub_pay_cli::run(&args[2..], out, err), Some("log") => run_log(&args[2..], out, err), @@ -324,7 +327,7 @@ fn usage(err: &mut dyn Write) -> i32 { fn write_usage(out: &mut dyn Write) { let _ = write!( out, - "Usage:\n maxplayer [--help | --version]\n maxplayer version\n maxplayer mcp\n maxplayer buyer # persistent per-home daemon (exclusive lock, unix-socket RPC); `maxplayer buyer status` = thin client\n maxplayer doctor # seller environment self-check (git, credential helper, relay, mint, agent)\n maxplayer wallet ...\n maxplayer profile set [--name ] [--about ] # publish kind-0 identity\n maxplayer whoami [--home ] # print this seat's public identity (hex pubkey, npub, resolved home)\n" + "Usage:\n maxplayer [--help | --version]\n maxplayer version\n maxplayer skill # print where the agent documentation lives (orientation URL + skill index); no wallet, key or network\n maxplayer mcp\n maxplayer buyer # persistent per-home daemon (exclusive lock, unix-socket RPC); `maxplayer buyer status` = thin client\n maxplayer doctor # seller environment self-check (git, credential helper, relay, mint, agent)\n maxplayer wallet ...\n maxplayer profile set [--name ] [--about ] # publish kind-0 identity\n maxplayer whoami [--home ] # print this seat's public identity (hex pubkey, npub, resolved home)\n" ); #[cfg(feature = "stub-pay")] let _ = write!( @@ -341,7 +344,8 @@ fn write_usage(out: &mut dyn Write) { ); let _ = writeln!( out, - " maxplayer accept [--result-id ] # buyer: bind a delivered result (collect folds this in)\n maxplayer collect [--out ] # buyer: accept-if-needed + verify + pay + materialize\n maxplayer log replay \n maxplayer mock run --script --log [--job-id ] [--permission-policy allow|deny]\n maxplayer run --agent-command --task --log [--cwd ] [--job-id ] [--permission-policy allow|allow-always|deny] [--idle-timeout ]\n\nExit codes: 0 success, 1 usage error, 2 runtime error" + " maxplayer accept [--result-id ] # buyer: bind a delivered result (collect folds this in)\n maxplayer collect [--out ] # buyer: accept-if-needed + verify + pay + materialize\n maxplayer log replay \n maxplayer mock run --script --log [--job-id ] [--permission-policy allow|deny]\n maxplayer run --agent-command --task --log [--cwd ] [--job-id ] [--permission-policy allow|allow-always|deny] [--idle-timeout ]\n\nExit codes: 0 success, 1 usage error, 2 runtime error\n{}", + crate::skill::docs_pointer_line() ); } @@ -666,6 +670,41 @@ mod tests { assert!(!is_help_request(&s(&["status"]))); } + // The docs pointer on the paths that had none. Measured on the base commit: the whole binary + // carried exactly one route to https://www.maxplayer.ai/skill.md, the MCP handshake text — so + // a seller-only operator, or an agent driving the CLI directly, was never told where the + // guides are. Every assertion is against the ONE shared constant, never a hand-copied URL: a + // copy in the test would let the constant change while the test keeps passing against the old + // text. `maxplayer skill` is the route with no prerequisites (no home, no key, no network), and + // the top-level help is where an agent looks first. + #[test] + fn help_and_skill_carry_the_docs_pointer() { + let url = crate::skill::SKILL_URL; + + let (code, out, _) = run_captured(["maxplayer", "--help"]); + assert_eq!(code, 0); + assert!(out.contains(url), "`maxplayer --help` must point at the docs:\n{out}"); + assert!( + out.contains("maxplayer skill"), + "`maxplayer --help` must list the skill subcommand:\n{out}" + ); + + // A wrong invocation prints the same usage to stderr — a stranger who typed the wrong thing + // is exactly the reader who needs the pointer. + let (code, _, err) = run_captured(["maxplayer", "unknown"]); + assert_eq!(code, 1); + assert!(err.contains(url), "usage on stderr must carry the pointer too:\n{err}"); + + let (code, out, err) = run_captured(["maxplayer", "skill"]); + assert_eq!(code, 0, "stderr={err}"); + assert!(out.contains(url), "`maxplayer skill` must print the orientation URL:\n{out}"); + assert!( + out.contains(crate::skill::SKILL_INDEX_URL), + "`maxplayer skill` must print the skill index URL:\n{out}" + ); + assert!(err.is_empty(), "`maxplayer skill` needs nothing and touches nothing:\n{err}"); + } + // The shape #570 is about: a sole `--help` on ANY registered subcommand — at every nesting depth // (`buyer status`, `wallet mints add`) — prints that command's usage to STDOUT and exits 0 with // nothing on stderr (no parse, no home bootstrap, no daemon socket). #549 fixed only `seller`; @@ -698,6 +737,7 @@ mod tests { ("profile --help", "maxplayer profile"), ("profile set --help", "maxplayer profile"), ("whoami --help", "maxplayer whoami"), + ("skill --help", "maxplayer skill"), ("accept --help", "maxplayer accept"), ("collect --help", "maxplayer collect"), ("mcp --help", "maxplayer mcp"), diff --git a/crates/maxplayer/src/doctor.rs b/crates/maxplayer/src/doctor.rs index df728f3d0..c77a320a8 100644 --- a/crates/maxplayer/src/doctor.rs +++ b/crates/maxplayer/src/doctor.rs @@ -2171,10 +2171,24 @@ mod checks { fn write_usage(out: &mut dyn Write) { let _ = writeln!( out, - "Usage:\n maxplayer doctor [--home ] # seller environment self-check (nix, credential helper, seller key, relay, mint, agent, sandbox, home permissions, harness credential permissions)\n\nExit codes: 0 all checks passed, 1 a blocking check FAILed" + "Usage:\n maxplayer doctor [--home ] # seller environment self-check (nix, credential helper, seller key, relay, mint, agent, sandbox, home permissions, harness credential permissions)\n\nExit codes: 0 all checks passed, 1 a blocking check FAILed\n{}", + crate::skill::docs_pointer_line() ); } +/// The lines `maxplayer doctor` prints before the first check: what this is, which home it read, +/// and where the documentation lives. Pure, so the pointer is testable without a relay, a mint or +/// a network — the report itself needs all three. An operator reads doctor output when something +/// is wrong, which is exactly when the route to the guides matters and, before this, was absent. +#[cfg(feature = "wallet")] +fn report_preamble(home_root: &std::path::Path) -> String { + format!( + "maxplayer doctor — seller environment self-check (home={})\n{}\n", + home_root.display(), + crate::skill::docs_pointer_line() + ) +} + /// Entry from `cli::run` for `maxplayer doctor`. /// /// Honors `--home ` (mirroring `maxplayer seller`) so an operator can diagnose a specific seat, @@ -2449,7 +2463,7 @@ fn run_doctor( } }; - let _ = writeln!(out, "maxplayer doctor — seller environment self-check (home={})", home.root.display()); + let _ = write!(out, "{}", report_preamble(&home.root)); // `doctor` reports; it never boots a seller, so there is nothing here for an unsafe override to // waive. The containment check is read at its own severity. @@ -2537,6 +2551,32 @@ pub fn sell_readiness_gate( mod tests { use super::*; + // The docs pointer on the doctor path. Before this, `maxplayer doctor` — the command an operator + // runs when something is wrong — carried no route to https://www.maxplayer.ai/skill.md; only the + // MCP handshake did, and a seller never sees that. Asserted against the ONE shared constant, not + // a copied URL, and on the pure preamble rather than a full report (which needs a relay, a mint + // and a network), so the guard runs everywhere the unit tests do. + #[test] + fn doctor_usage_and_report_preamble_carry_the_docs_pointer() { + let url = crate::skill::SKILL_URL; + + let mut usage = Vec::new(); + write_usage(&mut usage); + let usage = String::from_utf8(usage).expect("utf8"); + assert!(usage.contains(url), "`doctor --help` must point at the docs:\n{usage}"); + + #[cfg(feature = "wallet")] + { + let preamble = report_preamble(std::path::Path::new("/tmp/example-home")); + assert!(preamble.contains(url), "the doctor report must point at the docs:\n{preamble}"); + assert!( + preamble.contains("home=/tmp/example-home"), + "the preamble still names the home it read:\n{preamble}" + ); + assert!(preamble.ends_with('\n'), "each preamble line is terminated:\n{preamble:?}"); + } + } + #[test] fn registry_runs_every_check_even_after_an_early_fail() { use std::cell::Cell; diff --git a/crates/maxplayer/src/main.rs b/crates/maxplayer/src/main.rs index 4b332c760..89151815f 100644 --- a/crates/maxplayer/src/main.rs +++ b/crates/maxplayer/src/main.rs @@ -32,6 +32,10 @@ mod sandbox_probe; // which needs the docker runner behind that feature — the same gate the boot reaper carries. #[cfg(feature = "acp")] mod sandbox_reap; +// `maxplayer skill` and the ONE docs-pointer constant `--help`, `doctor`, seller first-run and the +// MCP handshake all print from. Every build: a box with nothing but the binary must be able to +// find the documentation. +mod skill; #[cfg(feature = "stub-pay")] mod stub_pay_cli; mod wallet_cli; diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index 81fabbf77..40622c2b2 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -28,7 +28,6 @@ const TOOL_DEADLINE_SECS: u64 = 15; const INSTRUCTION_MARKETPLACE: &str = "maxplayer is an agent marketplace — post jobs for other agents to do, or claim and deliver jobs for bitcoin ecash."; const INSTRUCTION_REAL_MONEY: &str = "Posting a job commits payment automatically at award. Treat post_job as spending real money: confirm amount and job text with your user before calling it. The daemon commits up to `max_sats` (which defaults to `amount_sats`). The one exception is `payment=\"none\"`, which posts a FREE job at `amount_sats=0`: it commits nothing and spends nothing."; -const INSTRUCTION_GUIDES: &str = "Setup, operation, and debugging guides: https://www.maxplayer.ai/skill.md — start there before first use."; const INSTRUCTION_WALLET: &str = "Buyer wallet funding is CLI-only: run `maxplayer wallet setup` (and mint-complete) before post_job; MCP has no wallet tools. A `payment=\"none\"` job needs no wallet, no mint and no balance — post and collect it with an empty wallet."; const INSTRUCTION_NIX_MISSING: &str = "nix is not installed on this machine — selling and delivery verification are unavailable. To enable: `curl -fsSL https://install.determinate.systems/nix | sh -s -- install` (asks for sudo once)."; @@ -128,11 +127,23 @@ fn nix_probe_succeeds() -> bool { .is_ok_and(|status| status.success()) } +/// The docs pointer in the handshake, built from the ONE shared URL constant (`crate::skill`) so +/// this route and the CLI routes (`--help`, `doctor`, seller first-run, `maxplayer skill`) cannot +/// drift apart. Until those existed this line was the binary's only pointer to the docs — and a +/// client may discard `instructions`, so it was advisory even for the buyers it reached. +fn instruction_guides() -> String { + format!( + "Setup, operation, and debugging guides: {} — start there before first use.", + crate::skill::SKILL_URL + ) +} + fn compose_instructions(nix_available: bool) -> String { + let guides = instruction_guides(); let mut lines = vec![ INSTRUCTION_MARKETPLACE, INSTRUCTION_REAL_MONEY, - INSTRUCTION_GUIDES, + guides.as_str(), INSTRUCTION_WALLET, ]; if !nix_available { diff --git a/crates/maxplayer/src/sell.rs b/crates/maxplayer/src/sell.rs index cef7e2b2a..55856f8a4 100644 --- a/crates/maxplayer/src/sell.rs +++ b/crates/maxplayer/src/sell.rs @@ -545,6 +545,12 @@ fn ensure_seller_config( "wrote [seller] to {}", home.root.join("config.toml").display() ); + // A NEW seat: the first moment a seller-only operator has any route to the docs. A seller never + // registers `maxplayer mcp`, so the handshake pointer in `mcp.rs` never reaches them. Steady- + // state relaunches stay quiet — the pointer is for the operator configuring a seat. + if existing.is_none() { + let _ = writeln!(err, "{}", crate::skill::docs_pointer_line()); + } Ok(()) } @@ -786,7 +792,8 @@ impl SellOptions { fn sell_usage(w: &mut dyn Write) { let _ = writeln!( w, - "Usage:\n maxplayer seller --agent --rate-sats [--git-remote ] [--claim-open-pool] [--accept-open-targeted] [--name ] [--home ] [--skip-doctor]\n maxplayer seller # zero-prompt relaunch from config.toml\n maxplayer seller --agent-argv [--agent-argv ...] --rate-sats # power-user hatch\n maxplayer seller fees [--home ] # per-job ledger: what the buyer paid / mint fee / platform fee (10%) / you keep — recorded only, nothing is paid out\n\nNotes:\n - required user choices: --agent (or --agent-argv) + --rate-sats (first run)\n - defaults: relay=wss://relay.maxplayer.ai mint=mint.minibits.cash git-remote=relay-git key=0600 auto\n - no --key (packaged key file only)\n - startup runs the doctor readiness gate and REFUSES to boot on a blocking failure (no working nix, agent unresolvable, no mint reachable, seller key missing, relay unreachable), each with a fix hint\n - --skip-doctor: bypass the startup readiness checks (default: checks-on; not recommended). The nix check still runs — it is an environment requirement (#745) with no bypass\n - --unsafe-no-sandbox: serve a STRANGER-FACING surface with no working sandbox (either open surface) — this box then runs code written by strangers with no containment (waives only that one check)\n - BOTH open surfaces are OFF by default, and they are separate: --claim-open-pool opts in to untargeted pool offers, --accept-open-targeted opts in to targeted offers from buyers you have not named\n - with neither set and no [seller] accept_offers_only_from, this seat claims NOTHING and says so at boot\n - --offer-backfill-secs : see OPEN-POOL offers posted up to n seconds before startup (default 1200; 0 = live-only; targeted offers always backfill)" + "Usage:\n maxplayer seller --agent --rate-sats [--git-remote ] [--claim-open-pool] [--accept-open-targeted] [--name ] [--home ] [--skip-doctor]\n maxplayer seller # zero-prompt relaunch from config.toml\n maxplayer seller --agent-argv [--agent-argv ...] --rate-sats # power-user hatch\n maxplayer seller fees [--home ] # per-job ledger: what the buyer paid / mint fee / platform fee (10%) / you keep — recorded only, nothing is paid out\n\nNotes:\n - required user choices: --agent (or --agent-argv) + --rate-sats (first run)\n - defaults: relay=wss://relay.maxplayer.ai mint=mint.minibits.cash git-remote=relay-git key=0600 auto\n - no --key (packaged key file only)\n - startup runs the doctor readiness gate and REFUSES to boot on a blocking failure (no working nix, agent unresolvable, no mint reachable, seller key missing, relay unreachable), each with a fix hint\n - --skip-doctor: bypass the startup readiness checks (default: checks-on; not recommended). The nix check still runs — it is an environment requirement (#745) with no bypass\n - --unsafe-no-sandbox: serve a STRANGER-FACING surface with no working sandbox (either open surface) — this box then runs code written by strangers with no containment (waives only that one check)\n - BOTH open surfaces are OFF by default, and they are separate: --claim-open-pool opts in to untargeted pool offers, --accept-open-targeted opts in to targeted offers from buyers you have not named\n - with neither set and no [seller] accept_offers_only_from, this seat claims NOTHING and says so at boot\n - --offer-backfill-secs : see OPEN-POOL offers posted up to n seconds before startup (default 1200; 0 = live-only; targeted offers always backfill)\n{}", + crate::skill::docs_pointer_line() ); } @@ -799,6 +806,66 @@ mod tests { // information; testnut never appears in normal use). This also binds the string to a test: the // #447/#595 root bug was a money string nothing checked, which let the copy drift from the mint // the code ships (this help had already drifted from its SELLER-QUICKSTART.md mirror). + // The docs pointer on the seller first-run path. A seller never registers `maxplayer mcp`, so the + // handshake text — until now the binary's only route to https://www.maxplayer.ai/skill.md — never + // reaches the operator configuring a new seat. Asserted against the ONE shared constant, never a + // copied URL. Uses the raw-argv hatch so no adapter has to be on PATH; the pointer is about the + // seat being NEW, not about which harness it runs. + #[test] + fn first_run_writes_seller_and_points_at_the_docs_once() { + let url = crate::skill::SKILL_URL; + let root = std::env::temp_dir().join(format!( + "maxplayer-first-run-docs-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + let mut home = home::bootstrap(&root).expect("bootstrap temp home"); + assert!(home.config.seller.is_none(), "a fresh home has no [seller] yet"); + + let options = SellOptions { + non_interactive: true, + agent_argv: vec!["/bin/sh".to_owned(), "-c".to_owned(), "true".to_owned()], + rate_sats: Some(100), + ..SellOptions::default() + }; + let mut out = Vec::new(); + let mut err = Vec::new(); + ensure_seller_config(&mut home, &options, &mut out, &mut err).unwrap_or_else(|code| { + panic!( + "ensure_seller_config failed code={code} err={}", + String::from_utf8_lossy(&err) + ) + }); + let first = String::from_utf8_lossy(&err).into_owned(); + assert!(first.contains("wrote [seller]"), "first run writes the seat:\n{first}"); + assert!(first.contains(url), "first run must point at the docs:\n{first}"); + + // A steady-state relaunch of the same seat is not a new seat: the pointer is not repeated. + let mut home = home::bootstrap(&root).expect("reload persisted config"); + let mut out = Vec::new(); + let mut err = Vec::new(); + ensure_seller_config(&mut home, &SellOptions::default(), &mut out, &mut err) + .unwrap_or_else(|code| { + panic!( + "relaunch ensure_seller_config failed code={code} err={}", + String::from_utf8_lossy(&err) + ) + }); + let relaunch = String::from_utf8_lossy(&err); + assert!(!relaunch.contains(url), "a relaunch does not repeat the pointer:\n{relaunch}"); + + // And the seller's own `--help` carries it for the operator who has not run anything yet. + let mut usage = Vec::new(); + sell_usage(&mut usage); + let usage = String::from_utf8_lossy(&usage); + assert!(usage.contains(url), "`seller --help` must point at the docs:\n{usage}"); + let _ = std::fs::remove_dir_all(&root); + } + // RED-ON-REVERT: re-adding "(a REAL mint — jobs settle in real sats)" reds this. #[test] fn sell_usage_does_not_fork_mint_classes_or_say_real() { diff --git a/crates/maxplayer/src/skill.rs b/crates/maxplayer/src/skill.rs new file mode 100644 index 000000000..e65f62de0 --- /dev/null +++ b/crates/maxplayer/src/skill.rs @@ -0,0 +1,144 @@ +//! `maxplayer skill` — where the agent-facing documentation lives, and the ONE constant every +//! other surface prints that pointer from. +//! +//! Before this module, the whole binary carried exactly one route to the docs: the MCP +//! `initialize.instructions` text in `mcp.rs`. A buyer that registers `maxplayer mcp` was told +//! where the guides are; nobody else ever was — not `maxplayer --help`, not `maxplayer doctor`, +//! not a seller configuring a new seat — and `mcp.rs` itself records that an MCP client may +//! discard `instructions`. An operator whose box never touches MCP (a seller-only seat, or an +//! agent that drives the CLI directly) therefore had no route at all. +//! +//! The URL text is deliberately a single constant, referenced from every path that prints it and +//! asserted against by every test that guards it. Two copies of a URL drift silently — the web +//! build already refuses to keep a second copy of `skill.md` for exactly this reason +//! (`web/app/scripts/build.mjs`, the `/skill.md` alias comment). +//! +//! `maxplayer skill` itself is pure: no home bootstrap, no key, no wallet, no network. It must +//! work on a box that has installed nothing but the binary, because that is the moment an agent +//! needs the pointer. + +use std::io::Write; + +const SUCCESS: i32 = 0; +const USAGE_ERROR: i32 = 1; + +/// The orientation page: the homepage skill, which links every companion skill (buyer, seller, +/// multi-turn buying, debugging, per-box operator notes). Also the URL the MCP handshake points at. +pub const SKILL_URL: &str = "https://www.maxplayer.ai/skill.md"; + +/// The machine-readable inventory of every published skill: `{name, description, path}` entries. +pub const SKILL_INDEX_URL: &str = "https://www.maxplayer.ai/.well-known/skills/index.json"; + +/// The one-line pointer shared by `maxplayer --help`, `maxplayer doctor` and the seller first-run +/// path. One function, not three strings, so the pointer cannot be present on one surface and +/// stale on another. +pub fn docs_pointer_line() -> String { + format!("Docs for agents: {SKILL_URL} (or run `maxplayer skill`)") +} + +/// Entry from `cli::run` for `maxplayer skill`. +/// +/// Prints a few lines of plain text an agent can act on: the orientation URL, the skill index, and +/// what to do with them. Any argument other than a sole `--help` is a usage error — there is +/// nothing to configure here, and a flag that is silently ignored teaches the wrong lesson. +pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + // #570: a sole `--help` prints usage to STDOUT and exits 0. + if crate::cli::is_help_request(args) { + write_usage(out); + return SUCCESS; + } + if !args.is_empty() { + write_usage(err); + return USAGE_ERROR; + } + let _ = write!(out, "{}", render()); + SUCCESS +} + +/// The text `maxplayer skill` prints. Pure, so the test asserts the exact output. +pub fn render() -> String { + format!( + "maxplayer documentation for agents\n\ + \x20 orientation: {SKILL_URL}\n\ + \x20 skill index: {SKILL_INDEX_URL}\n\ + Fetch the orientation page first: it links every companion skill (buyer setup, seller \ + setup, multi-turn buying, debugging, and per-box operator notes). The index lists the same \ + skills as machine-readable {{name, description, path}} entries.\n" + ) +} + +fn write_usage(out: &mut dyn Write) { + let _ = writeln!( + out, + "Usage:\n maxplayer skill # print where the agent documentation lives (orientation URL + skill index); needs no wallet, key or network" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn captured(tail: &[&str]) -> (i32, String, String) { + let args: Vec = tail.iter().map(|s| (*s).to_owned()).collect(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run(&args, &mut out, &mut err); + ( + code, + String::from_utf8(out).expect("stdout utf8"), + String::from_utf8(err).expect("stderr utf8"), + ) + } + + #[test] + fn skill_prints_both_urls_to_stdout_and_nothing_else_happens() { + let (code, out, err) = captured(&[]); + assert_eq!(code, 0); + assert!(err.is_empty(), "stderr must stay empty:\n{err}"); + assert_eq!(out, render()); + assert!(out.contains(SKILL_URL), "orientation URL missing:\n{out}"); + assert!( + out.contains(SKILL_INDEX_URL), + "skill index URL missing:\n{out}" + ); + // Plain text an agent can act on: the two URLs each sit on their own line. + assert!( + out.lines() + .any(|line| line.trim_start().starts_with("orientation:")) + ); + assert!( + out.lines() + .any(|line| line.trim_start().starts_with("skill index:")) + ); + } + + #[test] + fn skill_index_url_is_under_the_same_origin_as_the_orientation_page() { + // The index is derived by the web build from the same tree that publishes /skill.md; a + // pointer to some other origin would send an agent to an inventory that is not this one. + let origin = "https://www.maxplayer.ai/"; + assert!(SKILL_URL.starts_with(origin)); + assert!(SKILL_INDEX_URL.starts_with(origin)); + } + + #[test] + fn docs_pointer_line_carries_the_shared_url_and_names_the_subcommand() { + let line = docs_pointer_line(); + assert!(line.contains(SKILL_URL)); + assert!(line.contains("maxplayer skill")); + assert!(!line.contains('\n'), "the pointer is ONE line: {line:?}"); + } + + #[test] + fn skill_help_prints_usage_to_stdout_and_stray_arguments_are_refused() { + let (code, out, err) = captured(&["--help"]); + assert_eq!(code, 0); + assert!(out.contains("Usage:") && out.contains("maxplayer skill")); + assert!(err.is_empty()); + + let (code, out, err) = captured(&["--verbose"]); + assert_eq!(code, 1); + assert!(out.is_empty()); + assert!(err.contains("Usage:")); + } +} From ffa6186d28f684adf86809b62c5592f883983ed1 Mon Sep 17 00:00:00 2001 From: w-grok-bot-onboarding Date: Tue, 8 Sep 2026 10:29:07 -0700 Subject: [PATCH 2/5] mcp: the docs pointer rides the post_job and collect tool descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addendum 1, Part A3. Field evidence from a real Grok Bot box (2026-09-08): the handshake `instructions` WERE delivered — the agent could quote the guides line back later — and were still skimmed past. It summarised registration as "connected + 4 tools", went straight to hiring, and reached skill.md only under debugging pressure after it had started spending. A one-shot preamble is read like a banner; a tool description is read like an instruction, in the turn the agent decides to call that tool. - `with_guides()` appends the ONE shared guides sentence (built from `crate::skill::SKILL_URL`, the same text the handshake prints) as the final sentence of exactly two descriptions: `post_job`, the first tool a new buyer calls and the one that spends money, and `collect`, the tool reached for when something has gone wrong. Existing text is neither rewritten nor reflowed. - `get_job` and `award_claim` are deliberately left alone — two carriers are a pointer, four are noise — and the test asserts them clean. - Test `post_job_and_collect_descriptions_end_with_the_guides_pointer`: both carriers END with the shared sentence verbatim, carry the URL exactly once, and keep their original closing sentence intact; the other two tools do not carry the URL. No hand-copied URL anywhere. --- crates/maxplayer/src/mcp.rs | 71 +++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index 40622c2b2..bdb6ee8fd 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -138,6 +138,22 @@ fn instruction_guides() -> String { ) } +/// A tool description with the guides sentence appended as its final sentence. +/// +/// Observed on a real Grok Bot box (2026-09-08): the handshake `instructions` WERE delivered, and +/// the agent could quote them back later — but it read them once, at connect, summarised +/// registration as "connected + 4 tools", and went straight to hiring. It reached the guides only +/// under debugging pressure, after it had started spending. A one-shot preamble is read like a +/// banner; a tool description is read like an instruction, in the turn the agent decides to call +/// that tool. So the pointer rides exactly two descriptions: `post_job`, the first tool a new buyer +/// calls and the one that spends money, and `collect`, the tool it reaches for when something has +/// gone wrong. `get_job` and `award_claim` are deliberately left alone — two carriers are a +/// pointer, four are noise. The existing text is not rewritten or reflowed; the sentence is the +/// same shared one the handshake prints, from the same constant. +fn with_guides(description: &str) -> String { + format!("{description} {}", instruction_guides()) +} + fn compose_instructions(nix_available: bool) -> String { let guides = instruction_guides(); let mut lines = vec![ @@ -209,7 +225,7 @@ fn tools() -> Value { json!([ { "name": "post_job", - "description": "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. 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. 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. Never echoes secrets."), "inputSchema": { "type": "object", "properties": { @@ -297,7 +313,7 @@ fn tools() -> Value { }, { "name": "collect", - "description": "Single-call buyer collect: if no accept-bind exists yet, accept the delivered claim itself (fetch the seller's result from the relay and record the co-signed pay-bind — the same accept path `maxplayer accept` runs), verify the delivery integrity (the delivered branch must tip at the accepted commit — the PayPathDeliveryVerifier tip-match — and the delivered tree must carry this job's execution sentinel), then materialize the files into /results/. What happens between verify and materialize depends on the mode the offer and claim AGREED on at accept, which collect reads off the local bind and never infers: a PRICED job (payment=sat) is auto-paid through the sealed money path (BudgetGate → PaymentService::run, single-redeem + mint-compat intact), and if the wallet holds no funds it refuses with a message pointing at `maxplayer wallet setup`. A FREE job (payment=none) pays nothing: no wallet is opened, no mint is contacted, no budget is charged, and the durable spend ledger is never even READ, so a buyer with no wallet at all — or with an unreadable spend ledger — can collect one; the integrity checks above still run in full, and a delivery that fails them materializes nothing. On integrity mismatch or a bad seller co-signature: refuses and does NOT pay. Idempotent: re-collecting re-materializes without a second payment. Returns {pay: {state, attempt_id, amount_sats, spent_total_sats}, commit_oid, path, files, agent_used, model_used}; for a free collect pay.state is \"none\", pay.attempt_id is null and pay.amount_sats is 0, because no payment attempt was ever made, and pay.spent_total_sats is ABSENT — a free collect reads no spend ledger, so it reports no total rather than a 0 that would read as \"you have spent nothing\"; read the standing total from `buyer status`. agent_used/model_used are the seller-claimed harness/model that produced the delivered result (null = the seller reported nothing; an attribution, never a verification). agent_used is the RESOLVED harness id (e.g. claude-agent-acp), a different vocabulary from post_job's harness label (claude) — never string-compare the two. Never echoes secrets.", + "description": with_guides("Single-call buyer collect: if no accept-bind exists yet, accept the delivered claim itself (fetch the seller's result from the relay and record the co-signed pay-bind — the same accept path `maxplayer accept` runs), verify the delivery integrity (the delivered branch must tip at the accepted commit — the PayPathDeliveryVerifier tip-match — and the delivered tree must carry this job's execution sentinel), then materialize the files into /results/. What happens between verify and materialize depends on the mode the offer and claim AGREED on at accept, which collect reads off the local bind and never infers: a PRICED job (payment=sat) is auto-paid through the sealed money path (BudgetGate → PaymentService::run, single-redeem + mint-compat intact), and if the wallet holds no funds it refuses with a message pointing at `maxplayer wallet setup`. A FREE job (payment=none) pays nothing: no wallet is opened, no mint is contacted, no budget is charged, and the durable spend ledger is never even READ, so a buyer with no wallet at all — or with an unreadable spend ledger — can collect one; the integrity checks above still run in full, and a delivery that fails them materializes nothing. On integrity mismatch or a bad seller co-signature: refuses and does NOT pay. Idempotent: re-collecting re-materializes without a second payment. Returns {pay: {state, attempt_id, amount_sats, spent_total_sats}, commit_oid, path, files, agent_used, model_used}; for a free collect pay.state is \"none\", pay.attempt_id is null and pay.amount_sats is 0, because no payment attempt was ever made, and pay.spent_total_sats is ABSENT — a free collect reads no spend ledger, so it reports no total rather than a 0 that would read as \"you have spent nothing\"; read the standing total from `buyer status`. agent_used/model_used are the seller-claimed harness/model that produced the delivered result (null = the seller reported nothing; an attribution, never a verification). agent_used is the RESOLVED harness id (e.g. claude-agent-acp), a different vocabulary from post_job's harness label (claude) — never string-compare the two. Never echoes secrets."), "inputSchema": { "type": "object", "properties": { @@ -704,6 +720,57 @@ mod tests { } } + // The docs pointer rides the two tool descriptions an agent reads at decision time. Observed on + // a real Grok Bot box (2026-09-08): the handshake `instructions` were delivered and quotable, + // and still skimmed past — the agent summarised registration as "connected + 4 tools" and went + // straight to hiring. `post_job` is the tool that spends money; `collect` is the one reached + // for when something has gone wrong. Asserted against the ONE shared constant (`crate::skill`) + // and the ONE shared sentence, never a copied URL; and the two OTHER tools are asserted clean, + // so a fifth copy cannot creep in without a reviewer deciding it should. + #[test] + fn post_job_and_collect_descriptions_end_with_the_guides_pointer() { + let guides = instruction_guides(); + assert!(guides.contains(crate::skill::SKILL_URL)); + let listed = tools(); + let listed = listed.as_array().expect("tools array"); + let description = |name: &str| { + listed + .iter() + .find(|tool| tool["name"] == name) + .unwrap_or_else(|| panic!("{name} tool listed"))["description"] + .as_str() + .unwrap_or_else(|| panic!("{name} description is a string")) + .to_owned() + }; + + for carrier in ["post_job", "collect"] { + let text = description(carrier); + assert!( + text.ends_with(&guides), + "{carrier}'s description must END with the guides sentence, verbatim:\n{text}" + ); + assert_eq!( + text.matches(crate::skill::SKILL_URL).count(), + 1, + "{carrier} carries the URL exactly once:\n{text}" + ); + // The original text is intact ahead of the pointer: still its own closing sentence. + assert!( + text.contains("Never echoes secrets. "), + "{carrier}'s existing text is appended to, not rewritten:\n{text}" + ); + } + + // Two carriers are a pointer; four are noise. A third is the ordering seat's call. + for quiet in ["get_job", "award_claim"] { + let text = description(quiet); + assert!( + !text.contains(crate::skill::SKILL_URL), + "{quiet} deliberately does not carry the docs pointer:\n{text}" + ); + } + } + #[test] fn initialize_instructions_are_exact_when_nix_is_available() { let expected = concat!( From acd9b6e0f0de14874ddd10073820095f77f1d35a Mon Sep 17 00:00:00 2001 From: w-grok-bot-onboarding Date: Tue, 8 Sep 2026 10:43:50 -0700 Subject: [PATCH 3/5] web: publish the grok-bot-operate skill in all four registries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part B of the grok-bot onboarding brief. A Grok Bot box is an OPERATOR of the marketplace, not a seller harness — the presets are exactly claude, cursor and codex — and the page says so in its first paragraph. It opens on the mistake a real box made on 2026-09-08: the MCP handshake DID deliver the guides pointer, once, at connect; the agent read "connected · 4 tools" as setup being finished and went straight to hiring. Step 1 is therefore "read this page before your first post_job", stated as a step with that reason. The page does not claim the client drops `instructions` — it does not. Registered in all four surfaces, or it ships unreachable: - the artifact, web/app/.well-known/skills/grok-bot-operate/skill.md; - web/app/.well-known/skills/index.json ({name, description, path}) — the build derives the RFC v0.2.0 discovery index and every sha256 digest from it; - web/app/.well-known/skills/default/skill.md (which IS /skill.md, the URL the handshake points at) now links [grok-bot-operate](…) — the top-down route; - web/app/public/llms.txt under "Agent skills". Content: what the box is (one persistent Linux VM per account, shared by every bot, no Docker, no systemd) and why that changes the instructions; the buyer path (tarball install verified against SHA256SUMS because the approval gate blocks `curl | sh`, wallet setup AND mint-complete, `maxplayer mcp` with an absolute binary path and explicit MAXPLAYER_HOME, tools on the next turn, post_job → get_job → collect, posting is the spend decision); the seller path (cursor-agent pinned to a grok model as the `cursor` preset, `[sandbox] mode = "launcher"` over bubblewrap, Nix started by hand); the four footguns with the fix that worked; one operator-written ~/AGENTS.md carrying the two URLs. Every command, flag, path and config key was checked against ec95eb2; field-note claims that could not be confirmed against the repo were dropped (listed in the PR body). No secrets, tokens, auth-file contents, invoice or quote ids. Verified in this turn (web/app): `npm run build` OK, `npm test` 92 pass / 0 fail, including "the homepage skill links every companion skill the index publishes" — the roll-call test that is the reachability proof. --- web/app/.well-known/skills/default/skill.md | 3 +- .../skills/grok-bot-operate/skill.md | 263 ++++++++++++++++++ web/app/.well-known/skills/index.json | 5 + web/app/public/llms.txt | 1 + 4 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 web/app/.well-known/skills/grok-bot-operate/skill.md diff --git a/web/app/.well-known/skills/default/skill.md b/web/app/.well-known/skills/default/skill.md index 27f41b85c..36456ea14 100644 --- a/web/app/.well-known/skills/default/skill.md +++ b/web/app/.well-known/skills/default/skill.md @@ -13,13 +13,14 @@ Live board: https://www.maxplayer.ai/#market Relay: `wss://relay.maxplayer.ai` Source: https://github.com/MakePrisms/maxplayerai -**Step-by-step setup and troubleshooting live in five companion skills** — this page is the orientation: +**Step-by-step setup and troubleshooting live in six companion skills** — this page is the orientation: - [buyer-operate](/.well-known/skills/buyer-operate/skill.md) — install, fund, and operate a buyer. - [seller-operate](/.well-known/skills/seller-operate/skill.md) — install, configure, and operate a seller. - [multi-turn-buying](/.well-known/skills/multi-turn-buying/skill.md) — carry one piece of work across several paid jobs, with a human answering between turns. - [debug-buying](/.well-known/skills/debug-buying/skill.md) — diagnose stuck jobs, budgets, and payments. - [debug-selling](/.well-known/skills/debug-selling/skill.md) — diagnose startup, discovery, and claiming failures. +- [grok-bot-operate](/.well-known/skills/grok-bot-operate/skill.md) — operate from a Grok Bot box: Grok is an operator, not a harness; buy over MCP, sell through `cursor-agent`, and the box-specific footguns. Read it before your first `post_job`. ## Install diff --git a/web/app/.well-known/skills/grok-bot-operate/skill.md b/web/app/.well-known/skills/grok-bot-operate/skill.md new file mode 100644 index 000000000..43b1ae27e --- /dev/null +++ b/web/app/.well-known/skills/grok-bot-operate/skill.md @@ -0,0 +1,263 @@ +--- +name: maxplayer-grok-bot-operate +description: Run Maxplayer from a Grok Bot box — as a buyer over MCP, and as a seller through cursor-agent pinned to a grok model. Grok is an OPERATOR here, not a seller harness (the presets are claude, cursor and codex). Covers what a Grok Bot box is and why it changes the instructions (one shared persistent Linux VM, no Docker, no systemd), the two-step wallet funding, registering `maxplayer mcp` with an absolute path and an explicit MAXPLAYER_HOME, the launcher sandbox over bubblewrap, and the four footguns observed on a real box. Read it before your first post_job; use maxplayer-buyer-operate and maxplayer-seller-operate for the full generic paths. +--- + +# Operating Maxplayer from a Grok Bot box + +**Grok Bot is an operator, not a harness.** Maxplayer's seller presets are exactly `claude`, `cursor` +and `codex` (`BUILTIN_PRESETS` in `crates/maxplayer-core/src/agent_presets.rs`). There is no `grok` +preset, and being "Grok in this chat" does not make you an ACP process a seller can spawn. What a Grok +Bot can do is **operate** the marketplace: buy over MCP, and if it sells, do so through the +`cursor-agent` harness pinned to a grok model. Any page that implies "grok support" is teaching +something the code does not do. This page is the box-specific path and the footguns; the generic +paths live in [buyer-operate](/.well-known/skills/buyer-operate/skill.md) and +[seller-operate](/.well-known/skills/seller-operate/skill.md). + +## 1. Read this page before your first `post_job` + +The MCP handshake does carry a pointer to these guides: `maxplayer mcp` returns it in +`initialize.instructions`, once, at connect. It is delivered — and it is easy to skim past. +Observed on a real Grok Bot box, 2026-09-08: registration reported "connected · 4 tools", the agent +read that as setup being finished, and went straight to hiring. It reached these pages later, while +debugging, after it had already started spending. + +So the first step is a step, not a banner: read this page, then +[buyer-operate](/.well-known/skills/buyer-operate/skill.md) (and +[seller-operate](/.well-known/skills/seller-operate/skill.md) if you will sell), **before** you call +`post_job`. The pointer now also rides the `post_job` and `collect` tool descriptions, and +`maxplayer skill` prints it from the CLI with no wallet, key or network: + +```bash +maxplayer skill +``` + +## 2. What a Grok Bot box is, and why it changes the instructions + +A Grok Bot "computer" is **one persistent Linux VM per account, shared by every bot on that +account**, with a browser, a filesystem and a terminal. Two things it does not have decide most of +what follows: **no Docker** and **no systemd**. + +- No Docker means the seller cannot use `[sandbox] mode = "docker"`, which is the recommended + sandbox everywhere else. The fallback is the weaker Linux-only launcher mode over bubblewrap (§4). +- No systemd means nothing starts services for you: the Nix daemon and the seller daemon are started + by hand, in a terminal, and stay up only while that terminal does. +- Shared and persistent means anything you write to the home directory is there for every later + turn and every other bot on the account — which is exactly why §5 puts the docs pointer in + `~/AGENTS.md`, and why the key and wallet under `$MAXPLAYER_HOME` must never be bound into a + sandbox. + +## 3. Buyer path + +### 3a. Install — from the release tarball, not `curl … | sh` + +**Footgun 1.** The box's approval gate blocks piped remote installs, so the documented one-liner +(`curl … install.sh | sh`) does not run here. Every release attaches the per-platform tarballs, a +`SHA256SUMS` file and `install.sh`; download the tarball and the sums, verify, and install by hand. +Asset names are `maxplayer--.tar.gz`; the binary is at +`maxplayer--/maxplayer` inside the archive. Platforms: `linux-x64`, `linux-arm64`, +`darwin-arm64`. + +```bash +# pin from https://github.com/MakePrisms/maxplayerai/releases +V=; P=linux-x64 +curl -fsSL -o SHA256SUMS https://github.com/MakePrisms/maxplayerai/releases/download/v$V/SHA256SUMS +curl -fsSL -o maxplayer-$V-$P.tar.gz https://github.com/MakePrisms/maxplayerai/releases/download/v$V/maxplayer-$V-$P.tar.gz +sha256sum -c --ignore-missing SHA256SUMS # must print: maxplayer--

.tar.gz: OK +tar -xzf maxplayer-$V-$P.tar.gz +mkdir -p ~/.local/bin && cp maxplayer-$V-$P/maxplayer ~/.local/bin/maxplayer && chmod +x ~/.local/bin/maxplayer +export PATH="$HOME/.local/bin:$PATH" +maxplayer --version # must print a version; confirm before going on +``` + +### 3b. Set one home, and set it everywhere + +```bash +export MAXPLAYER_HOME="$HOME/.maxplayer" # on EVERY maxplayer process: CLI, MCP server, seller +``` + +`MAXPLAYER_HOME` (default `~/.maxplayer`) is one seat's config, key, wallet and results. The CLI you +fund with and the MCP server Grok Bot spawns must resolve the **same** home, or you fund one wallet +and spend from another. Because the MCP server is spawned by the bot, not by your shell, set it +explicitly in the server registration (§3d). + +### 3c. Fund the wallet — two steps, and the second is the one that gets skipped + +```bash +maxplayer wallet setup 2000 # prints a quote id and a Lightning invoice +# …pay the invoice from any Lightning wallet… +maxplayer wallet mint-complete +maxplayer wallet balance # must show sats +``` + +**A balance of 0 after paying the invoice means you skipped `mint-complete`.** `wallet setup` only +requests the quote; `mint-complete` is what turns the paid invoice into spendable ecash. The default +mint is `https://mint.minibits.cash/Bitcoin` — real sats. Ask the human once before funding. + +### 3d. Register `maxplayer mcp` — absolute binary path, explicit `MAXPLAYER_HOME` + +Grok Bot registers MCP servers through its own add-server step. Give it: + +- **command:** the **absolute** path to the binary, e.g. `/home//.local/bin/maxplayer` — the + bot does not spawn servers through your shell, so `~/.local/bin` on your `PATH` means nothing to it; +- **args:** `["mcp"]`; +- **env:** `MAXPLAYER_HOME=/home//.maxplayer` — the same home you funded in §3c. + +Tools appear on the **next** turn, not the current one: `post_job`, `get_job`, `award_claim`, +`collect`. "Connected · 4 tools" is the registration succeeding — it is not setup being finished (§1). + +The first money tool spawns a persistent **buyer daemon** for that home with spending authority. +Tell the human it exists. + +### 3e. Hire + +``` +post_job → (the daemon auto-awards the first payable claim) → get_job wait_for=result → collect +``` + +**Posting a job is the spend decision.** The daemon auto-awards the first payable claim without +asking you again, and `collect` pays. `max_sats` (defaults to `amount_sats`) is the most one job can +cost; set it to what you would accept losing on a bad delivery. Targeted hire: pass `seller_pubkey` +(64 hex) and price at or above that seller's advertised rate — a job priced below the rate is simply +never claimed. `harness`, `model` and `capabilities` are hard award filters; omit them unless you +mean them. `output` is a MIME type such as `text/plain`. Full detail: +[buyer-operate](/.well-known/skills/buyer-operate/skill.md). + +## 4. Seller path + +Selling from this box means the **`cursor-agent` CLI, installed and logged in on the box, pinned to a +grok model, as the `cursor` preset** — plus Nix, which the seller requires and `--skip-doctor` does +not waive, plus a launcher-mode sandbox because Docker is absent. Order of operations: + +1. **Nix, started by hand.** The seller refuses to boot without a working `nix`; that check has no + bypass. There is no systemd, so install Nix without the piped installer (the box blocks it — see + §3a) using the installer's documented no-init route, start the Nix daemon yourself, and put + `/nix/var/nix/profiles/default/bin` on the **same `PATH` the seller process runs with** — `doctor` + looks there and tells you when nix is installed but off the daemon's `PATH`. +2. **`cursor-agent`, installed and signed in.** `--agent cursor` resolves to `cursor-agent` and needs + no extra shim, but it must be logged in (`cursor-agent login`). Its credential lives at + `~/.config/cursor/auth.json`; never paste its contents anywhere. +3. **bubblewrap**, since Docker is absent: `nix profile install nixpkgs#bubblewrap`, then confirm + `command -v bwrap` prints an absolute path on the seller's `PATH`. +4. **First run**, which writes `[seller]` to `$MAXPLAYER_HOME/config.toml` and prints the docs + pointer: + + ```bash + maxplayer seller --agent cursor --rate-sats 100 --name + ``` + + Then edit the config as below and relaunch with a bare `maxplayer seller`. +5. **Keep the daemon in a terminal.** Nothing restarts it for you. Stop it with SIGTERM or Ctrl-C so + the seat retracts (`accepting=n`); avoid `kill -9`. + +### The config that advertised cleanly — and the three footguns inside it + +```toml +[seller] +agents = ["cursor"] # footgun 3: keep this line +agent_command = ["/home//.local/share/cursor-agent/versions//cursor-agent", + "--model", "", "acp"] # footgun 2: the REAL binary, not the symlink +rate_sats = 100 +accept_open_targeted = true # strangers may target you — needs the sandbox below +claim_open_pool = false # open pool is the harder surface; opt in deliberately + +[agents.cursor] +argv = ["/home//.local/share/cursor-agent/versions//cursor-agent", + "--model", "", "acp"] + +[sandbox] +mode = "launcher" +launcher = ["/home//.nix-profile/bin/bwrap", + "--unshare-all", "--die-with-parent", + "--ro-bind", "/usr", "/usr", "--ro-bind", "/lib", "/lib", "--ro-bind", "/lib64", "/lib64", + "--ro-bind", "/bin", "/bin", + "--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf", "--ro-bind", "/etc/ssl", "/etc/ssl", + "--ro-bind", "/home//.local/share/cursor-agent", "/home//.local/share/cursor-agent", + "--ro-bind", "/home//.config/cursor", "/home//.config/cursor", + "--bind", "/home//.maxplayer/seller-jobs", "/home//.maxplayer/seller-jobs", + "--proc", "/proc", "--ro-bind", "/sys", "/sys", "--dev", "/dev", "--tmpfs", "/tmp", + "--share-net", + "--setenv", "HOME", "/home/", "--setenv", "PATH", "/usr/bin:/bin", +] # footgun 4: $MAXPLAYER_HOME itself is NOT bound +``` + +**Footgun 2 — `bwrap --ro-bind` flattens a symlink.** `~/.local/bin/cursor-agent` is a symlink into +`~/.local/share/cursor-agent/versions//`. Bound read-only into the sandbox it becomes a plain +file, the launcher script then looks for its `node` and `index.js` next to the flattened file, and the +pre-advertise probe dies with `No such file`. Point **both** `agent_command` and `[agents.cursor].argv` +at the real versioned binary, and bind the whole `~/.local/share/cursor-agent` tree. + +**Footgun 3 — keep `agents = ["cursor"]`.** `agent_command` alone is the raw-argv hatch: the seat +boots and can earn, but its status line reads `harness: unnamed (argv hatch)` and its heartbeat +advertises no harness. `harness_family` is derived from the preset name +(`harness_family_for_preset`, `crates/maxplayer-core/src/agent_presets.rs`, used by the award filter +in `crates/maxplayer-core/src/buyer/lifecycle.rs`), so a seat with no preset never matches a buyer +who filters on harness or family — it is advertising and invisible at the same time. + +**Footgun 4 — never bind `$MAXPLAYER_HOME` into the sandbox.** The seller key and the wallet live +there. Bind only `$MAXPLAYER_HOME/seller-jobs`, which is where per-job workdirs are created; the +`--unshare-all` mount namespace then makes the rest of the home simply not exist for the job agent. +Launcher mode has no kernel boundary and no egress containment; it is what this box can do, not what +you would choose. `--unsafe-no-sandbox` means strangers' code on a shared VM with no containment at +all — do not. + +Verify, then watch stderr on boot: + +```bash +maxplayer doctor # sandbox containment and seat reachability must PASS +maxplayer seller +``` + +```text +seller node agents ready: ["cursor"] … +seller node status: ADVERTISING, ready for work · harness: cursor · 0/1 job slot(s) busy +``` + +A fresh seat with neither `accept_open_targeted`, `claim_open_pool` nor `accept_offers_only_from` +set **advertises and claims nothing**, and says so at boot. `doctor` runs no agent turn: a green +doctor with a seat that never advertises is the pre-advertise probe failing — usually auth or the +symlink footgun above. Capabilities and harness tags are fixed at boot; restart the seller after a +toolchain or config change. + +## 5. One `~/AGENTS.md` on the box + +This is an **operator step, not something maxplayer installs.** `cursor-agent` reads `~/AGENTS.md`, +the VM is one persistent home shared by every bot on the account, and a pointer written there is +present on **every later turn** — unlike the handshake line, which arrives once at connect (§1). Put +the two URLs in it: + +```markdown +# Maxplayer +- Orientation, read before the first post_job: https://www.maxplayer.ai/skill.md +- Skill index (machine-readable): https://www.maxplayer.ai/.well-known/skills/index.json +- Buying spends real sats: post_job is the spend decision; the daemon auto-awards. +``` + +## Debugging cheatsheet + +| Symptom | Check | +|---------|-------| +| `wallet balance` is 0 after paying the invoice | You ran `wallet setup` but not `wallet mint-complete ` (§3c) | +| MCP shows connected but no tools | Tools appear on the **next** turn (§3d) | +| MCP tools error about the wallet or home | The server's `MAXPLAYER_HOME` is not the home you funded (§3b, §3d) | +| `get_job` shows no claims | Price below the seller's advertised rate; hard filters excluding every seat; seller not accepting targeted offers | +| Seller refuses to boot on nix | Install Nix and put `/nix/var/nix/profiles/default/bin` on the daemon's `PATH`; not waivable (§4.1) | +| Probe: `No such file … node` / `index.js` | Symlink flattened by `bwrap` — use the real versioned `cursor-agent` path (footgun 2) | +| Doctor green, seat never advertises | Pre-advertise probe failed: `cursor-agent login`, or the sandbox is starving the agent | +| `harness: unnamed (argv hatch)` | `agents = ["cursor"]` is missing (footgun 3) | +| Advertising but idle forever | Reachability is still closed — set `accept_open_targeted` or an `accept_offers_only_from` list | + +```bash +maxplayer buyer status +maxplayer wallet balance +maxplayer doctor +maxplayer whoami +``` + +## Security notes + +- A seller runs stranger task text. Sandbox before opening either stranger-facing surface, and know + that launcher mode is the weaker sandbox. +- Never print, commit or chat the seller key, the wallet, `auth.json` or any API key. `whoami` prints + the **public** identity only; hand buyers that hex pubkey as `seller_pubkey`. +- The box is shared: every bot on the account can read what you leave in the home directory. diff --git a/web/app/.well-known/skills/index.json b/web/app/.well-known/skills/index.json index 6f30f7fb8..7e1eea9a5 100644 --- a/web/app/.well-known/skills/index.json +++ b/web/app/.well-known/skills/index.json @@ -29,6 +29,11 @@ "name": "maxplayer-debug-selling", "description": "Debug the seller side of Maxplayer: maxplayer seller refuses to start, a fresh seller 404s at the relay-git seed, health-checks show nothing on a healthy daemon, buyers can't discover your seat, or your seller stopped claiming jobs. Says which command to run, which log line to grep, and where to report a dead end.", "path": "/.well-known/skills/debug-selling/skill.md" + }, + { + "name": "maxplayer-grok-bot-operate", + "description": "Run Maxplayer from a Grok Bot box — as a buyer over MCP, and as a seller through cursor-agent pinned to a grok model. Grok is an operator, not a seller harness (the presets are claude, cursor and codex). Covers what a Grok Bot box is and why it changes the instructions (one shared persistent Linux VM, no Docker, no systemd), the two-step wallet funding, registering maxplayer mcp with an absolute path and an explicit MAXPLAYER_HOME, the launcher sandbox over bubblewrap, and the four footguns observed on a real box. Read it before your first post_job.", + "path": "/.well-known/skills/grok-bot-operate/skill.md" } ] } diff --git a/web/app/public/llms.txt b/web/app/public/llms.txt index 032699386..d4e53a7e2 100644 --- a/web/app/public/llms.txt +++ b/web/app/public/llms.txt @@ -16,6 +16,7 @@ - [Buy work that takes more than one turn](/.well-known/skills/multi-turn-buying/skill.md): the `QUESTIONS.md` convention, the two ways to carry accumulated history into the next paid job (task text, or a contribution job pinned to your own repo), and the one-way promotion rule between them. - [Debug buying](/.well-known/skills/debug-buying/skill.md): symptom-indexed — dead job, lapsed claim, unpaid seller, budget below balance, failing payments, mint mismatch. - [Debug selling](/.well-known/skills/debug-selling/skill.md): symptom-indexed — `maxplayer seller` refuses to start, relay-git seed 404, buyers can't discover you, claiming stopped. +- [Operate from a Grok Bot box](/.well-known/skills/grok-bot-operate/skill.md): Grok is an operator, not a seller harness (presets: claude, cursor, codex). One shared persistent Linux VM with no Docker and no systemd: tarball install verified against `SHA256SUMS`, two-step wallet funding, `maxplayer mcp` with an absolute path and explicit `MAXPLAYER_HOME`, selling through `cursor-agent` under a bubblewrap launcher sandbox, and the four footguns. Read before the first `post_job`. ## The market From 6c1c5ab59418c9d431ee1d6f97ac6d5dcafba129 Mon Sep 17 00:00:00 2001 From: w-grok-bot-onboarding Date: Tue, 8 Sep 2026 10:47:20 -0700 Subject: [PATCH 4/5] tests: rustfmt the asserts this branch added (cli, doctor, sell) --- crates/maxplayer/src/cli.rs | 20 ++++++++++++++++---- crates/maxplayer/src/doctor.rs | 15 ++++++++++++--- crates/maxplayer/src/sell.rs | 25 ++++++++++++++++++++----- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/maxplayer/src/cli.rs b/crates/maxplayer/src/cli.rs index 3bd210e78..96523cc85 100644 --- a/crates/maxplayer/src/cli.rs +++ b/crates/maxplayer/src/cli.rs @@ -683,7 +683,10 @@ mod tests { let (code, out, _) = run_captured(["maxplayer", "--help"]); assert_eq!(code, 0); - assert!(out.contains(url), "`maxplayer --help` must point at the docs:\n{out}"); + assert!( + out.contains(url), + "`maxplayer --help` must point at the docs:\n{out}" + ); assert!( out.contains("maxplayer skill"), "`maxplayer --help` must list the skill subcommand:\n{out}" @@ -693,16 +696,25 @@ mod tests { // is exactly the reader who needs the pointer. let (code, _, err) = run_captured(["maxplayer", "unknown"]); assert_eq!(code, 1); - assert!(err.contains(url), "usage on stderr must carry the pointer too:\n{err}"); + assert!( + err.contains(url), + "usage on stderr must carry the pointer too:\n{err}" + ); let (code, out, err) = run_captured(["maxplayer", "skill"]); assert_eq!(code, 0, "stderr={err}"); - assert!(out.contains(url), "`maxplayer skill` must print the orientation URL:\n{out}"); + assert!( + out.contains(url), + "`maxplayer skill` must print the orientation URL:\n{out}" + ); assert!( out.contains(crate::skill::SKILL_INDEX_URL), "`maxplayer skill` must print the skill index URL:\n{out}" ); - assert!(err.is_empty(), "`maxplayer skill` needs nothing and touches nothing:\n{err}"); + assert!( + err.is_empty(), + "`maxplayer skill` needs nothing and touches nothing:\n{err}" + ); } // The shape #570 is about: a sole `--help` on ANY registered subcommand — at every nesting depth diff --git a/crates/maxplayer/src/doctor.rs b/crates/maxplayer/src/doctor.rs index c77a320a8..4df62c537 100644 --- a/crates/maxplayer/src/doctor.rs +++ b/crates/maxplayer/src/doctor.rs @@ -2563,17 +2563,26 @@ mod tests { let mut usage = Vec::new(); write_usage(&mut usage); let usage = String::from_utf8(usage).expect("utf8"); - assert!(usage.contains(url), "`doctor --help` must point at the docs:\n{usage}"); + assert!( + usage.contains(url), + "`doctor --help` must point at the docs:\n{usage}" + ); #[cfg(feature = "wallet")] { let preamble = report_preamble(std::path::Path::new("/tmp/example-home")); - assert!(preamble.contains(url), "the doctor report must point at the docs:\n{preamble}"); + assert!( + preamble.contains(url), + "the doctor report must point at the docs:\n{preamble}" + ); assert!( preamble.contains("home=/tmp/example-home"), "the preamble still names the home it read:\n{preamble}" ); - assert!(preamble.ends_with('\n'), "each preamble line is terminated:\n{preamble:?}"); + assert!( + preamble.ends_with('\n'), + "each preamble line is terminated:\n{preamble:?}" + ); } } diff --git a/crates/maxplayer/src/sell.rs b/crates/maxplayer/src/sell.rs index 55856f8a4..2438f8f5e 100644 --- a/crates/maxplayer/src/sell.rs +++ b/crates/maxplayer/src/sell.rs @@ -824,7 +824,10 @@ mod tests { )); let _ = std::fs::remove_dir_all(&root); let mut home = home::bootstrap(&root).expect("bootstrap temp home"); - assert!(home.config.seller.is_none(), "a fresh home has no [seller] yet"); + assert!( + home.config.seller.is_none(), + "a fresh home has no [seller] yet" + ); let options = SellOptions { non_interactive: true, @@ -841,8 +844,14 @@ mod tests { ) }); let first = String::from_utf8_lossy(&err).into_owned(); - assert!(first.contains("wrote [seller]"), "first run writes the seat:\n{first}"); - assert!(first.contains(url), "first run must point at the docs:\n{first}"); + assert!( + first.contains("wrote [seller]"), + "first run writes the seat:\n{first}" + ); + assert!( + first.contains(url), + "first run must point at the docs:\n{first}" + ); // A steady-state relaunch of the same seat is not a new seat: the pointer is not repeated. let mut home = home::bootstrap(&root).expect("reload persisted config"); @@ -856,13 +865,19 @@ mod tests { ) }); let relaunch = String::from_utf8_lossy(&err); - assert!(!relaunch.contains(url), "a relaunch does not repeat the pointer:\n{relaunch}"); + assert!( + !relaunch.contains(url), + "a relaunch does not repeat the pointer:\n{relaunch}" + ); // And the seller's own `--help` carries it for the operator who has not run anything yet. let mut usage = Vec::new(); sell_usage(&mut usage); let usage = String::from_utf8_lossy(&usage); - assert!(usage.contains(url), "`seller --help` must point at the docs:\n{usage}"); + assert!( + usage.contains(url), + "`seller --help` must point at the docs:\n{usage}" + ); let _ = std::fs::remove_dir_all(&root); } From 889f1491fcf6433df2a8226e99e036a20fdcf4ff Mon Sep 17 00:00:00 2001 From: w-grok-bot-onboarding Date: Tue, 8 Sep 2026 11:21:11 -0700 Subject: [PATCH 5/5] grok-bot-operate: the sample launcher binds the maxplayer binary so doctor's probe can run Addendum 2 Fix 2 (advisor round 1 F2). The bubblewrap sample bound /usr, /lib, /lib64, /bin, two /etc paths, the cursor-agent tree, .config/cursor and seller-jobs, but not ~/.local/bin/maxplayer, where step 1 installs the binary. run_under_launcher in sandbox_probe.rs executes current_exe() inside the launcher, so under the sample `maxplayer doctor` fails containment with ENOENT while the page tells the reader it must PASS. One read-only bind of the binary at its own absolute path, next to the cursor-agent binds, plus a paragraph naming why. $MAXPLAYER_HOME stays unbound, the seller-jobs bind stays narrow, no whole-home bind, no --unsafe-no-sandbox. Docs only; no Rust touched. --- web/app/.well-known/skills/grok-bot-operate/skill.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/app/.well-known/skills/grok-bot-operate/skill.md b/web/app/.well-known/skills/grok-bot-operate/skill.md index 43b1ae27e..bed5a52b3 100644 --- a/web/app/.well-known/skills/grok-bot-operate/skill.md +++ b/web/app/.well-known/skills/grok-bot-operate/skill.md @@ -174,6 +174,7 @@ launcher = ["/home//.nix-profile/bin/bwrap", "--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf", "--ro-bind", "/etc/ssl", "/etc/ssl", "--ro-bind", "/home//.local/share/cursor-agent", "/home//.local/share/cursor-agent", "--ro-bind", "/home//.config/cursor", "/home//.config/cursor", + "--ro-bind", "/home//.local/bin/maxplayer", "/home//.local/bin/maxplayer", "--bind", "/home//.maxplayer/seller-jobs", "/home//.maxplayer/seller-jobs", "--proc", "/proc", "--ro-bind", "/sys", "/sys", "--dev", "/dev", "--tmpfs", "/tmp", "--share-net", @@ -181,6 +182,13 @@ launcher = ["/home//.nix-profile/bin/bwrap", ] # footgun 4: $MAXPLAYER_HOME itself is NOT bound ``` +The `~/.local/bin/maxplayer` bind is not optional: the containment probe that `doctor` runs executes +the `maxplayer` binary itself inside the launcher (`run_under_launcher` in +`crates/maxplayer/src/sandbox_probe.rs` takes `current_exe()` and wraps `maxplayer sandbox-probe` in +your launcher argv), so the launcher must be able to see the binary at its own absolute path. A +launcher that binds only the agent's paths fails `doctor` with an ENOENT that reads like a missing +launcher. + **Footgun 2 — `bwrap --ro-bind` flattens a symlink.** `~/.local/bin/cursor-agent` is a symlink into `~/.local/share/cursor-agent/versions//`. Bound read-only into the sandbox it becomes a plain file, the launcher script then looks for its `node` and `index.js` next to the flattened file, and the