diff --git a/Cargo.lock b/Cargo.lock
index c9d5f3d..5ebfb74 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2985,6 +2985,16 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "keyring"
+version = "3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
+dependencies = [
+ "log",
+ "zeroize",
+]
+
[[package]]
name = "kqueue"
version = "1.1.1"
@@ -3200,6 +3210,7 @@ dependencies = [
"hex",
"indicatif",
"inquire",
+ "keyring",
"rand 0.8.5",
"ratatui",
"reqwest 0.12.28",
diff --git a/assets/card-logo.svg b/assets/card-logo.svg
new file mode 100644
index 0000000..0f4f871
--- /dev/null
+++ b/assets/card-logo.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/mach-token-metadata.json b/assets/mach-token-metadata.json
index 642428f..4a3e408 100644
--- a/assets/mach-token-metadata.json
+++ b/assets/mach-token-metadata.json
@@ -2,13 +2,13 @@
"name": "Maschina",
"symbol": "MACH",
"description": "Maschina governance and utility token. Stake to participate in network governance, earn rewards, and access premium compute capacity on the agentic network.",
- "image": "https://arweave.net/At45mjn2d5h2VqcYVGrc9xNf4YAtSuMcaN5qChaH2VsU",
+ "image": "https://raw.githubusercontent.com/RustMunkey/maschina/main/assets/mach-token.png",
"external_url": "https://maschina.dev",
"attributes": [],
"properties": {
"files": [
{
- "uri": "https://arweave.net/At45mjn2d5h2VqcYVGrc9xNf4YAtSuMcaN5qChaH2VsU",
+ "uri": "https://raw.githubusercontent.com/RustMunkey/maschina/main/assets/mach-token.png",
"type": "image/png"
}
],
diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml
index ce8dfea..8d112c7 100644
--- a/packages/cli/Cargo.toml
+++ b/packages/cli/Cargo.toml
@@ -28,6 +28,7 @@ dirs = "5"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1"
thiserror = "1"
+keyring = "3"
arboard = { version = "3", default-features = false }
# Node binary deps
diff --git a/packages/cli/src/commands/node/join.rs b/packages/cli/src/commands/node/join.rs
index 4d1c024..2feba1c 100644
--- a/packages/cli/src/commands/node/join.rs
+++ b/packages/cli/src/commands/node/join.rs
@@ -186,7 +186,7 @@ pub async fn run(profile: &str, out: &Output) -> Result<()> {
// 7. Save to config
let node_cfg = NodeConfig {
node_id: node_id.clone(),
- signing_key: privkey_b64,
+ signing_key: Some(privkey_b64),
runtime_url,
nats_url,
nats_ca_cert,
diff --git a/packages/cli/src/config.rs b/packages/cli/src/config.rs
index e42e60d..ea4a42d 100644
--- a/packages/cli/src/config.rs
+++ b/packages/cli/src/config.rs
@@ -1,18 +1,21 @@
use anyhow::{Context, Result};
+use keyring::Entry;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub api_url: String,
+ /// Stored in OS keychain — field is None in the TOML file on disk.
+ #[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option,
pub email: Option,
- /// Database connection URL (sqlite path or postgres/neon URL)
+ /// Stored in OS keychain — field is None in the TOML file on disk.
#[serde(skip_serializing_if = "Option::is_none")]
pub db_url: Option,
- /// Configured AI model providers
+ /// Configured AI model providers (api_key fields stored in keychain)
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub model_providers: Vec,
@@ -20,7 +23,7 @@ pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub node: Option,
- /// Cached tier from last successful auth (access/m1/m5/m10/teams/enterprise/beta)
+ /// Cached tier from last successful auth
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option,
@@ -36,30 +39,55 @@ pub struct Config {
/// Stored when a machine joins the Maschina compute network.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeConfig {
- /// UUID assigned by the API on registration
pub node_id: String,
- /// Base64-encoded Ed25519 signing key (32 bytes) — used to sign execution receipts
- pub signing_key: String,
- /// URL of the local Python runtime this node will forward tasks to
+ /// Base64-encoded Ed25519 signing key — stored in OS keychain, not on disk.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signing_key: Option,
pub runtime_url: String,
- /// NATS server URL for receiving tasks
pub nats_url: String,
- /// Optional path to a CA certificate for TLS-enabled NATS connections
#[serde(skip_serializing_if = "Option::is_none")]
pub nats_ca_cert: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelProvider {
- /// e.g. "anthropic", "openai", "ollama", "openrouter", "gemini", "mistral"
pub name: String,
+ /// Stored in OS keychain — field is None in the TOML file on disk.
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option,
- /// For Ollama or custom OpenAI-compatible endpoints
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option,
}
+// ─── Keychain helpers ─────────────────────────────────────────────────────────
+
+const KEYRING_SERVICE: &str = "maschina";
+
+fn kr_key(profile: &str, field: &str) -> String {
+ format!("{profile}/{field}")
+}
+
+fn kr_get(profile: &str, field: &str) -> Option {
+ Entry::new(KEYRING_SERVICE, &kr_key(profile, field))
+ .ok()
+ .and_then(|e| e.get_password().ok())
+ .filter(|s| !s.is_empty())
+}
+
+fn kr_set(profile: &str, field: &str, value: &str) {
+ if let Ok(entry) = Entry::new(KEYRING_SERVICE, &kr_key(profile, field)) {
+ let _ = entry.set_password(value);
+ }
+}
+
+fn kr_del(profile: &str, field: &str) {
+ if let Ok(entry) = Entry::new(KEYRING_SERVICE, &kr_key(profile, field)) {
+ let _ = entry.delete_credential();
+ }
+}
+
+// ─── Config impl ──────────────────────────────────────────────────────────────
+
impl Config {
pub fn default_api_url() -> String {
"https://api.maschina.ai".to_string()
@@ -73,7 +101,7 @@ impl Config {
}
}
-/// ~/.config/maschina/.toml
+/// `~/.config/maschina/.toml`
pub fn config_path(profile: &str) -> Result {
let base = dirs::config_dir()
.or_else(dirs::home_dir)
@@ -86,36 +114,90 @@ pub fn config_path(profile: &str) -> Result {
Ok(base.join("maschina").join(filename))
}
+/// Load config from disk and inject secrets from OS keychain.
+/// Falls back to any plaintext value already in the TOML (handles migration
+/// from older versions that stored secrets in the file).
pub fn load(profile: &str) -> Result {
let path = config_path(profile)?;
- if !path.exists() {
- return Ok(Config {
+ let mut cfg = if path.exists() {
+ let contents = std::fs::read_to_string(&path)
+ .with_context(|| format!("failed to read {}", path.display()))?;
+ let mut c: Config = toml::from_str(&contents).context("failed to parse config.toml")?;
+ c.profile = profile.to_string();
+ c
+ } else {
+ Config {
api_url: Config::default_api_url(),
- api_key: None,
- email: None,
- db_url: None,
- model_providers: vec![],
- node: None,
- tier: None,
- tui_theme: None,
profile: profile.to_string(),
- });
+ ..Default::default()
+ }
+ };
+
+ // Inject secrets from keychain; fall back to TOML value so old plaintext
+ // configs continue to work until the next `save()` migrates them.
+ cfg.api_key = kr_get(profile, "api_key").or(cfg.api_key);
+ cfg.db_url = kr_get(profile, "db_url").or(cfg.db_url);
+
+ for provider in &mut cfg.model_providers {
+ let field = format!("provider/{}", provider.name);
+ provider.api_key = kr_get(profile, &field).or(provider.api_key.take());
+ }
+
+ if let Some(ref mut node) = cfg.node {
+ node.signing_key = kr_get(profile, "node_signing_key").or(node.signing_key.take());
}
- let contents = std::fs::read_to_string(&path)
- .with_context(|| format!("failed to read {}", path.display()))?;
- let mut cfg: Config = toml::from_str(&contents).context("failed to parse config.toml")?;
- cfg.profile = profile.to_string();
+
Ok(cfg)
}
-pub fn save(config: &Config, profile: &str) -> Result<()> {
+/// Persist secrets to OS keychain, then write TOML with those fields stripped.
+pub fn save(cfg: &Config, profile: &str) -> Result<()> {
+ // ── Write secrets to keychain ─────────────────────────────────────────────
+ match &cfg.api_key {
+ Some(v) if !v.is_empty() => kr_set(profile, "api_key", v),
+ None => kr_del(profile, "api_key"),
+ _ => {}
+ }
+ match &cfg.db_url {
+ Some(v) if !v.is_empty() => kr_set(profile, "db_url", v),
+ None => kr_del(profile, "db_url"),
+ _ => {}
+ }
+ for provider in &cfg.model_providers {
+ let field = format!("provider/{}", provider.name);
+ match &provider.api_key {
+ Some(v) if !v.is_empty() => kr_set(profile, &field, v),
+ None => kr_del(profile, &field),
+ _ => {}
+ }
+ }
+ if let Some(ref node) = cfg.node {
+ match &node.signing_key {
+ Some(v) if !v.is_empty() => kr_set(profile, "node_signing_key", v),
+ None => kr_del(profile, "node_signing_key"),
+ _ => {}
+ }
+ }
+
+ // ── Write TOML with secrets stripped ──────────────────────────────────────
+ let mut on_disk = cfg.clone();
+ on_disk.api_key = None;
+ on_disk.db_url = None;
+ for p in &mut on_disk.model_providers {
+ p.api_key = None;
+ }
+ if let Some(ref mut node) = on_disk.node {
+ node.signing_key = None;
+ }
+
let path = config_path(profile)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
- let contents = toml::to_string_pretty(config).context("failed to serialize config")?;
+ let contents = toml::to_string_pretty(&on_disk).context("failed to serialize config")?;
std::fs::write(&path, &contents)
.with_context(|| format!("failed to write {}", path.display()))?;
+
Ok(())
}
diff --git a/scripts/update-token-uri.ts b/scripts/update-token-uri.ts
new file mode 100644
index 0000000..4c9d8ac
--- /dev/null
+++ b/scripts/update-token-uri.ts
@@ -0,0 +1,94 @@
+/**
+ * update-token-uri.ts
+ *
+ * Updates the on-chain metadata URI for the MACH Token-2022 mint.
+ * Run this after the new metadata file is live on main (GitHub raw URL accessible).
+ *
+ * Run:
+ * SOLANA_CLUSTER=devnet npx tsx scripts/update-token-uri.ts
+ *
+ * Prerequisites:
+ * - MACH_MINT_ADDRESS in env (from setup-mach-token.ts output)
+ * - Keypair at ~/.config/solana/id.json (or ANCHOR_WALLET) — must be update authority
+ */
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { TOKEN_2022_PROGRAM_ID, createUpdateFieldInstruction } from "@solana/spl-token";
+import {
+ Connection,
+ Keypair,
+ PublicKey,
+ Transaction,
+ sendAndConfirmTransaction,
+} from "@solana/web3.js";
+
+const CLUSTER = process.env.SOLANA_CLUSTER ?? "devnet";
+
+const NEW_METADATA_URI =
+ "https://raw.githubusercontent.com/RustMunkey/maschina/main/assets/mach-token-metadata.json";
+
+function getRpcUrl(): string {
+ if (CLUSTER === "localnet") return "http://127.0.0.1:8899";
+ const apiKey = process.env.HELIUS_API_KEY;
+ if (apiKey) {
+ return CLUSTER === "mainnet-beta"
+ ? `https://mainnet.helius-rpc.com/?api-key=${apiKey}`
+ : `https://devnet.helius-rpc.com/?api-key=${apiKey}`;
+ }
+ return CLUSTER === "mainnet-beta"
+ ? "https://api.mainnet-beta.solana.com"
+ : "https://api.devnet.solana.com";
+}
+
+function loadKeypair(): Keypair {
+ const walletPath =
+ process.env.ANCHOR_WALLET ?? path.join(os.homedir(), ".config", "solana", "id.json");
+ const bytes = JSON.parse(fs.readFileSync(walletPath, "utf-8")) as number[];
+ return Keypair.fromSecretKey(Uint8Array.from(bytes));
+}
+
+async function main() {
+ const mintAddress = process.env.MACH_MINT_ADDRESS;
+ if (!mintAddress) {
+ console.error("MACH_MINT_ADDRESS is required.");
+ process.exit(1);
+ }
+
+ const connection = new Connection(getRpcUrl(), "confirmed");
+ const authority = loadKeypair();
+ const mint = new PublicKey(mintAddress);
+
+ console.log(`Cluster: ${CLUSTER}`);
+ console.log(`Mint: ${mintAddress}`);
+ console.log(`Authority: ${authority.publicKey.toBase58()}`);
+ console.log(`New URI: ${NEW_METADATA_URI}\n`);
+
+ // Token-2022 TokenMetadata extension — metadata lives at the mint address itself.
+ // updateField sets a single field by name; "uri" is the metadata URI.
+ const ix = createUpdateFieldInstruction({
+ programId: TOKEN_2022_PROGRAM_ID,
+ metadata: mint,
+ updateAuthority: authority.publicKey,
+ field: "uri",
+ value: NEW_METADATA_URI,
+ });
+
+ const tx = new Transaction().add(ix);
+ tx.feePayer = authority.publicKey;
+
+ console.log("Sending updateField transaction...");
+ const sig = await sendAndConfirmTransaction(connection, tx, [authority], {
+ commitment: "confirmed",
+ });
+
+ console.log("\nDone.");
+ console.log(` Signature: ${sig}`);
+ console.log(` New URI: ${NEW_METADATA_URI}`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});