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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions assets/card-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions assets/mach-token-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
],
Expand Down
1 change: 1 addition & 0 deletions packages/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/node/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
138 changes: 110 additions & 28 deletions packages/cli/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
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<String>,
pub email: Option<String>,

/// 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<String>,

/// 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<ModelProvider>,

/// Node participation config — set when this machine has joined the compute network
#[serde(skip_serializing_if = "Option::is_none")]
pub node: Option<NodeConfig>,

/// 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<String>,

Expand All @@ -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<String>,
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<String>,
}

#[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<String>,
/// For Ollama or custom OpenAI-compatible endpoints
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}

// ─── 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<String> {
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);
}
}
Comment on lines +77 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent failures in keychain writes may cause undetected secret loss.

Both kr_set and kr_del silently discard errors. If the keychain service is unavailable or the operation fails, the user won't know their secrets weren't persisted, potentially causing authentication failures on the next load().

Consider logging a warning or returning a Result so callers can decide how to handle failures.

🛡️ Proposed fix to propagate errors
-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_set(profile: &str, field: &str, value: &str) -> Result<()> {
+    let entry = Entry::new(KEYRING_SERVICE, &kr_key(profile, field))
+        .context("failed to create keychain entry")?;
+    entry.set_password(value)
+        .context("failed to write secret to keychain")?;
+    Ok(())
 }
 
-fn kr_del(profile: &str, field: &str) {
-    if let Ok(entry) = Entry::new(KEYRING_SERVICE, &kr_key(profile, field)) {
-        let _ = entry.delete_credential();
-    }
+fn kr_del(profile: &str, field: &str) -> Result<()> {
+    let entry = Entry::new(KEYRING_SERVICE, &kr_key(profile, field))
+        .context("failed to create keychain entry")?;
+    // Ignore "not found" errors on delete
+    let _ = entry.delete_credential();
+    Ok(())
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/config.rs` around lines 77 - 81, kr_set and kr_del currently
swallow all keyring errors which can hide secret write/delete failures; change
both functions (kr_set and kr_del) to return Result<(), E> (or a suitable
keyring::Error) instead of unit, propagate the error from Entry::new(...) and
entry.set_password(...) / entry.delete_password(...), and update callers to
handle or log the Result so failures are visible; keep the existing
KEYRING_SERVICE and kr_key(profile, field) usage but propagate errors from those
calls rather than discarding them.


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()
Expand All @@ -73,7 +101,7 @@ impl Config {
}
}

/// ~/.config/maschina/<profile>.toml
/// `~/.config/maschina/<profile>.toml`
pub fn config_path(profile: &str) -> Result<PathBuf> {
let base = dirs::config_dir()
.or_else(dirs::home_dir)
Expand All @@ -86,36 +114,90 @@ pub fn config_path(profile: &str) -> Result<PathBuf> {
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<Config> {
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"),
_ => {}
}
Comment on lines +156 to +160
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty string values don't delete stale keychain entries.

The _ => {} arm catches Some(""), leaving any existing keychain entry intact. If a user clears a field to an empty string (rather than None), the old secret remains in the keychain and will be loaded on next load().

Consider treating empty strings the same as None:

♻️ Proposed fix
 match &cfg.api_key {
     Some(v) if !v.is_empty() => kr_set(profile, "api_key", v),
-    None => kr_del(profile, "api_key"),
-    _ => {}
+    _ => kr_del(profile, "api_key"),
 }

Apply the same pattern to db_url, provider.api_key, and node.signing_key matches.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
match &cfg.api_key {
Some(v) if !v.is_empty() => kr_set(profile, "api_key", v),
None => kr_del(profile, "api_key"),
_ => {}
}
match &cfg.api_key {
Some(v) if !v.is_empty() => kr_set(profile, "api_key", v),
_ => kr_del(profile, "api_key"),
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/config.rs` around lines 156 - 160, The match for cfg.api_key
currently leaves Some("") untouched; change it so any empty string is treated
like None and deletes the keychain entry: use match &cfg.api_key { Some(v) if
!v.is_empty() => kr_set(profile, "api_key", v), _ => kr_del(profile, "api_key"),
} (i.e., make the fallback arm call kr_del instead of no-op). Apply the same
pattern to the similar matches for db_url, provider.api_key, and
node.signing_key so that Some("") also triggers kr_del for those keys.

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(())
}

Expand Down
94 changes: 94 additions & 0 deletions scripts/update-token-uri.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Comment on lines +32 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unrecognized cluster values silently fall back to devnet.

If SOLANA_CLUSTER is set to an unrecognized value (e.g., "testnet"), the function silently falls back to devnet RPC, which could lead to unexpected behavior. Consider validating the cluster value or logging a warning.

Suggested improvement
+const VALID_CLUSTERS = ["localnet", "devnet", "mainnet-beta"] as const;
+type Cluster = (typeof VALID_CLUSTERS)[number];
+
 function getRpcUrl(): string {
+  if (!VALID_CLUSTERS.includes(CLUSTER as Cluster)) {
+    console.warn(`Unknown cluster "${CLUSTER}", defaulting to devnet`);
+  }
   if (CLUSTER === "localnet") return "http://127.0.0.1:8899";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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";
}
const VALID_CLUSTERS = ["localnet", "devnet", "mainnet-beta"] as const;
type Cluster = (typeof VALID_CLUSTERS)[number];
function getRpcUrl(): string {
if (!VALID_CLUSTERS.includes(CLUSTER as Cluster)) {
console.warn(`Unknown cluster "${CLUSTER}", defaulting to devnet`);
}
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";
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/update-token-uri.ts` around lines 32 - 43, The getRpcUrl function
currently treats any non-"localnet" value as devnet, so validate CLUSTER (used
in getRpcUrl) against allowed values ("localnet", "devnet", "mainnet-beta"); if
the value is unrecognized, either throw a clear Error or emit a warning
(including the actual CLUSTER value) before falling back, and then proceed to
construct the RPC URL using the existing HELIUS_API_KEY logic; update getRpcUrl
to perform this explicit check and message so unexpected SOLANA_CLUSTER values
(e.g., "testnet") are surfaced instead of silently using devnet.


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