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
240 changes: 233 additions & 7 deletions Cargo.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,16 @@ authors = [
"Paolo D'Amico <paolodamico@users.noreply.github.com>",
"Andy Wang <41224501+andy-t-wang@users.noreply.github.com>"
]

[workspace.lints.rust]
missing_docs = "deny"
dead_code = "deny"

[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
expect_used = "warn"
panic = "deny"
dbg_macro = "deny"
todo = "deny"
mem_forget = "deny"
30 changes: 22 additions & 8 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,28 @@ name = "agentkit"
path = "src/main.rs"

[dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
dialoguer = { version = "0.12", default-features = false }
directories = "6"
getrandom = "0.3"
hex = "0.4"
secrecy = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
walletkit-core = "0.16"

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
expect_used = "warn"
panic = "deny"
dbg_macro = "deny"
todo = "deny"
mem_forget = "deny"
[dev-dependencies]
tempfile = "3"

[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["apple-native"] }

[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "linux-native", "vendored"] }

[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["windows-native"] }

[lints]
workspace = true
27 changes: 27 additions & 0 deletions client/scripts/codesign.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Ad-hoc code-sign the agentkit binary on macOS.
#
# Why: macOS Keychain trust ("Always Allow") is bound to a binary's code-signing
# identity. Without an explicit signature, every cargo rebuild produces a binary
# the Keychain treats as new and re-prompts for the user's password. Ad-hoc
# signing (-) gives the binary an identity; for trust that survives rebuilds,
# create a self-signed code-signing certificate in Keychain Access and pass its
# name via AGENTKIT_SIGN_IDENTITY.
#
# Run after `cargo build`. Pass `release` as the first argument to sign the
# release build instead of debug.

set -euo pipefail

profile="${1:-debug}"
identity="${AGENTKIT_SIGN_IDENTITY:--}"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
binary="${script_dir}/../../target/${profile}/agentkit"

if [[ ! -f "${binary}" ]]; then
echo "error: ${binary} not found; run 'cargo build' (or 'cargo build --release' for the release profile) first" >&2
exit 1
fi

codesign --force --sign "${identity}" "${binary}"
echo "signed ${binary} with identity '${identity}'"
125 changes: 125 additions & 0 deletions client/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};

use crate::storage::Backend;

const CONFIG_FILE: &str = "config.json";

/// Persistent user preferences for the `AgentKit` client, serialized as JSON in the application
/// data directory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub backend: Backend,
}

impl Config {
/// Reads the config from disk, returning `Ok(None)` when no config has been saved yet.
pub fn load(paths: &Paths) -> Result<Option<Self>> {
let path = paths.config_path();
match fs::read_to_string(&path) {
Ok(contents) => {
let cfg: Self = serde_json::from_str(&contents)
.with_context(|| format!("failed to parse config at {}", path.display()))?;
Ok(Some(cfg))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("failed to read config at {}", path.display())),
}
}

/// Writes the config to disk, creating the application data directory if it does not exist.
pub fn save(&self, paths: &Paths) -> Result<()> {
ensure_dir(&paths.data_dir)?;
let path = paths.config_path();
let contents = serde_json::to_string_pretty(self).context("failed to serialize config")?;
fs::write(&path, contents).with_context(|| format!("failed to write config to {}", path.display()))?;
Ok(())
}
}

/// Resolved filesystem locations the `AgentKit` client uses for configuration and storage.
pub struct Paths {
pub data_dir: PathBuf,
}

impl Paths {
/// Resolves the platform-appropriate application data directory (e.g.
/// `~/Library/Application Support/org.world.agentkit` on macOS).
pub fn discover() -> Result<Self> {
let project = ProjectDirs::from("org", "world", "agentkit")
.ok_or_else(|| anyhow!("could not determine application data directory for this platform"))?;
Ok(Self {
data_dir: project.data_dir().to_path_buf(),
})
}

/// Returns the absolute path to the JSON config file inside the data directory.
pub fn config_path(&self) -> PathBuf {
self.data_dir.join(CONFIG_FILE)
}
}

fn ensure_dir(dir: &Path) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("failed to create directory {}", dir.display()))
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use tempfile::TempDir;

use super::*;

fn paths_in(dir: &TempDir) -> Paths {
Paths {
data_dir: dir.path().join("data"),
}
}

#[test]
fn load_returns_none_when_missing() {
let dir = TempDir::new().unwrap();
assert!(Config::load(&paths_in(&dir)).unwrap().is_none());
}

#[test]
fn save_then_load_round_trips() {
let dir = TempDir::new().unwrap();
let paths = paths_in(&dir);

Config { backend: Backend::File }.save(&paths).unwrap();
let loaded = Config::load(&paths).unwrap().unwrap();

assert_eq!(loaded.backend, Backend::File);
assert!(paths.config_path().exists());
}

#[test]
fn save_creates_data_dir() {
let dir = TempDir::new().unwrap();
let paths = paths_in(&dir);
assert!(!paths.data_dir.exists());

Config {
backend: Backend::Keyring,
}
.save(&paths)
.unwrap();

assert!(paths.data_dir.is_dir());
}

#[test]
fn load_errors_on_malformed_json() {
let dir = TempDir::new().unwrap();
let paths = paths_in(&dir);
fs::create_dir_all(&paths.data_dir).unwrap();
fs::write(paths.config_path(), "{not valid json").unwrap();

assert!(Config::load(&paths).is_err());
}
}
82 changes: 82 additions & 0 deletions client/src/enroll.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
use anyhow::{Context, Result};
use dialoguer::{Confirm, Select, theme::ColorfulTheme};
use secrecy::{ExposeSecretMut, SecretSlice};

use crate::config::{Config, Paths};
use crate::storage::{self, Backend, SecretStore};

const SEED_LEN: usize = 32;

pub fn run() -> Result<()> {
let paths = Paths::discover()?;
let config = resolve_config(&paths)?;
let store = storage::open(config.backend, &paths.data_dir)?;

if store.read()?.is_some() && !confirm_overwrite(&*store)? {
println!("Aborted. Existing identity preserved.");
return Ok(());
}

let location = store.location();
store
.write(generate_seed()?)
.with_context(|| format!("failed to store identity in {location}"))?;

println!();
println!("AgentKit identity created.");
println!(" Storage: {}", store.location());

// TODO: Initiate registration with World ID
Ok(())
}

fn resolve_config(paths: &Paths) -> Result<Config> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit. i feel like this doesn't belong here, it's not specific to the enroll comamnd, maybe move it to config.rs

if let Some(existing) = Config::load(paths)? {
return Ok(existing);
}

let backend = prompt_backend()?;
let cfg = Config { backend };
cfg.save(paths)?;
Comment on lines +39 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist backend choice only after storage initialization succeeds

The selected backend is written to config.json before verifying it can be used; if a user accepts the recommended keychain option in an environment where keyring access fails, the broken keyring preference is persisted and subsequent runs reuse it without re-prompting. This leaves enrollment stuck until the config is manually edited or deleted.

Useful? React with 👍 / 👎.

println!(
"Saved storage preference: {} ({})",
backend.label(),
paths.config_path().display()
);
Ok(cfg)
}

fn prompt_backend() -> Result<Backend> {
let options = [
"System keychain (recommended) — uses your OS secure storage",
"File — stores the key on disk in the AgentKit data directory (less secure)",
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Where should AgentKit store its identity key?")
.items(options)
.default(0)
.interact()
.context("interactive prompt failed; run in a terminal or pre-configure storage")?;

let backend = match selection {
0 => Backend::Keyring,
_ => Backend::File,
};

Ok(backend)
}

fn confirm_overwrite(store: &dyn SecretStore) -> Result<bool> {
println!("An AgentKit identity already exists in {}.", store.location());
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Overwrite the existing identity? This cannot be undone.")
.default(false)
.interact()
.context("interactive prompt failed")
}

fn generate_seed() -> Result<SecretSlice<u8>> {
let mut secret = SecretSlice::from(vec![0u8; SEED_LEN]);
getrandom::fill(secret.expose_secret_mut()).context("failed to read randomness from the OS")?;
Ok(secret)
}
21 changes: 17 additions & 4 deletions client/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
//! `AgentKit` Client CLI. Allows agents to interact online with a delegated Proof of Human from
//! a user's World ID.

use std::process::ExitCode;

use clap::{Parser, Subcommand};

mod config;
mod enroll;
mod storage;

#[derive(Debug, Parser)]
#[command(
name = "agentkit",
Expand All @@ -22,9 +29,15 @@ enum Command {

fn main() -> ExitCode {
let cli = Cli::parse();
let name = match cli.command {
Command::Enroll => "enroll",
let result = match cli.command {
Command::Enroll => enroll::run(),
};
eprintln!("agentkit: `{name}` is not yet implemented (WIP-512 scaffold).");
ExitCode::from(1)

match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("agentkit: {err:#}");
ExitCode::from(1)
}
}
}
Loading