Skip to content
Closed
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
326 changes: 325 additions & 1 deletion crates/cli/src/scaffold/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use std::{
env,
fs::{self, File},
path::Path,
process::{Command, Stdio},
};

use dialoguer::{Confirm, Input, MultiSelect, console::Style, theme::ColorfulTheme};
Expand All @@ -18,7 +20,10 @@ use txtx_core::{
types::RunbookSources,
};

use crate::{cli::DEFAULT_SOLANA_KEYPAIR_PATH, types::Framework};
use crate::{
cli::{DEFAULT_SOLANA_KEYPAIR_PATH, get_home_dir},
types::Framework,
};

pub const SURFPOOL_README_TEMPLATE: &str = include_str!("./templates/readme.md.mst");

Expand Down Expand Up @@ -134,6 +139,121 @@ impl ProgramMetadata {
}
}

const DEV_SKILL_REPO: &str = "https://github.com/solana-foundation/solana-dev-skill";

/// Exact, not a range: anything looser installs whatever the registry calls latest that day.
const DEV_SKILL_INSTALLER: &str = "skills@1.5.23";

/// Named because the installer, detecting none of its own agents, otherwise fans the
/// skill out to every one of the 77 in its registry: `.claude/`, a non-hidden `agent/`
/// and a lock file all land in a project that asked for none of them. `universal` is
/// its own registry key for the canonical `.agents/skills` copy and symlinks nowhere.
const DEV_SKILL_AGENT: &str = "universal";

const DEV_SKILL_DIR: &str = ".agents/skills/solana-dev";

const DEV_SKILL_DECLINED_MARKER: &str = ".config/surfpool/dev-skill-declined";

const DEV_SKILL_ACCEPTED_MARKER: &str = ".config/surfpool/dev-skill-accepted";

/// Two `-y`, two programs: npx's auto-installs the package, the skills CLI's skips its prompts.
fn dev_skill_install_command(base_location: &FileLocation) -> Command {
let mut command = Command::new("npx");
command.args([
"-y",
DEV_SKILL_INSTALLER,
"add",
DEV_SKILL_REPO,
"--skill",
"*",
"--agent",
DEV_SKILL_AGENT,
"-y",
]);
command.current_dir(base_location.expect_path_buf());
command
}

/// Fire-and-forget: this sits on the path to booting a surfnet, so a missing Node or a failed
/// install is silence rather than an error, and the wait that reaps the child runs off-thread.
fn spawn_dev_skill_install(mut command: Command) {
let Ok(mut child) = command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return;
};
let _ = hiro_system_kit::thread_named("Dev Skill Install").spawn(move || {
let _ = child.wait();
});
}

fn dev_skill_installed(base: &Path, home: &Path) -> bool {
base.join(DEV_SKILL_DIR).exists() || home.join(DEV_SKILL_DIR).exists()
}

/// Both answers are ours to keep: `DEV_SKILL_DIR` is written by an installer we neither wait on
/// nor control, so a yes inferred from it is a yes asked again forever. A no wins a split pair.
fn dev_skill_answer(home: &Path) -> Option<bool> {
if home.join(DEV_SKILL_DECLINED_MARKER).exists() {
Some(false)
} else if home.join(DEV_SKILL_ACCEPTED_MARKER).exists() {
Some(true)
} else {
None
}
}

fn prompt_for_dev_skill(theme: &ColorfulTheme) -> Option<bool> {
Confirm::with_theme(theme)
.with_prompt(format!(
"Install the solana-dev skill? It writes {DEV_SKILL_DIR} and skills-lock.json here"
))
.default(false)
.interact()
.ok()
}

fn record_dev_skill_answer(home: &Path, consented: bool) {
let marker = home.join(match consented {
true => DEV_SKILL_ACCEPTED_MARKER,
false => DEV_SKILL_DECLINED_MARKER,
});
if let Some(parent) = marker.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = File::create(marker);
}

/// A recorded answer and an install on disk both outrank `--yes`. Paths are arguments so the
/// gate is testable without a real home directory.
fn dev_skill_install_if_wanted(
base_location: &FileLocation,
base: &Path,
home: &Path,
auto_accept: bool,
ask: impl FnOnce() -> Option<bool>,
) -> Option<Command> {
if dev_skill_installed(base, home) {
return None;
}
let consented = match dev_skill_answer(home) {
Some(recorded) => recorded,
None if auto_accept => true,
None => match ask() {
Some(answer) => {
record_dev_skill_answer(home, answer);
answer
}
// A prompt that could not be shown is not an answer, so nothing is recorded.
None => false,
},
};
consented.then(|| dev_skill_install_command(base_location))
}

pub fn scaffold_in_memory_iac(
framework: &Framework,
programs: &[ProgramMetadata],
Expand Down Expand Up @@ -512,5 +632,209 @@ pub fn scaffold_iac_layout(
println!("Deployment canceled");
}

// Last, so the install only follows a scaffold that finished, and deliberately not keyed to
// `confirmation`: declining the deployment is "not now", not "never".
let home = get_home_dir();
let base = base_location.expect_path_buf();
if let Some(command) = dev_skill_install_if_wanted(
base_location,
&base,
Path::new(&home),
auto_generate_runbooks,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Runbook flag bypasses installer consent

When a fresh project is started with --yes, the runbook-only flag is passed as auto_accept and silently authorizes npx to download and execute an external installer that writes .agents/skills/solana-dev and skills-lock.json. The flag is documented only as “Skip runbook generation prompts,” so this installation occurs without informed consent. How this was verified: The flag was traced from its CLI definition through the scaffold caller to the branch that bypasses the dedicated installation prompt and spawns npx.

|| prompt_for_dev_skill(&theme),
) {
spawn_dev_skill_install(command);
println!("{} {}", green!("Started installer for"), DEV_SKILL_DIR);
}

Ok(())
}

#[cfg(test)]
mod tests {
use std::{
fs::{self, File},
path::Path,
process::Command,
time::{Duration, Instant},
};

use tempfile::TempDir;

use super::{
DEV_SKILL_AGENT, DEV_SKILL_DECLINED_MARKER, DEV_SKILL_DIR, DEV_SKILL_INSTALLER,
DEV_SKILL_REPO, FileLocation, dev_skill_answer, dev_skill_install_command,
dev_skill_install_if_wanted, spawn_dev_skill_install,
};

/// Every gate test runs against these, never a real home directory.
fn scratch() -> (TempDir, TempDir, FileLocation) {
let base = TempDir::new().unwrap();
let home = TempDir::new().unwrap();
let location = FileLocation::from_path_string(base.path().to_str().unwrap()).unwrap();
(base, home, location)
}

#[cfg(unix)]
fn sh(script: &str) -> Command {
let mut command = Command::new("sh");
command.args(["-c", script]);
command
}

/// #567 supplied this invocation literally. The one departure from its text is the
/// pinned version, which is here in full so that dropping the pin has to fail this.
#[test]
fn the_install_is_the_command_issue_567_asked_for() {
let base = FileLocation::from_path_string("/tmp/surfpool-567-scaffold").unwrap();
let command = dev_skill_install_command(&base);
assert_eq!(command.get_program(), "npx");
assert_eq!(
command.get_args().collect::<Vec<_>>(),
[
"-y",
DEV_SKILL_INSTALLER,
"add",
DEV_SKILL_REPO,
"--skill",
"*",
"--agent",
DEV_SKILL_AGENT,
"-y"
]
);
// "*" is the installer's own alias for every agent it knows, so naming one
// agent and naming all of them are one typo apart.
assert_ne!(DEV_SKILL_AGENT, "*");
let (package, version) = DEV_SKILL_INSTALLER
.split_once('@')
.expect("installer is pinned");
assert_eq!(package, "skills");
assert!(
version.split('.').count() == 3
&& version
.split('.')
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())),
"the installer must stay pinned to an exact version, not a range: {version}"
);
}

/// `surfpool start -m ../elsewhere/txtx.yml` scaffolds a tree the caller is not standing in,
/// so the manifest's directory is the target and the shell's is not.
#[test]
fn the_install_runs_in_the_scaffolded_project_not_the_process_cwd() {
let base = FileLocation::from_path_string("/tmp/surfpool-567-scaffold").unwrap();
assert_eq!(
dev_skill_install_command(&base).get_current_dir(),
Some(Path::new("/tmp/surfpool-567-scaffold"))
);
}

/// A skill already on disk is the reviewer's first item: say nothing, do nothing.
/// Either scope counts, since a globally installed skill is already available here.
#[test]
fn an_installed_skill_is_left_alone() {
for (in_base, in_home) in [(true, false), (false, true)] {
let (base, home, location) = scratch();
let root = if in_base { base.path() } else { home.path() };
fs::create_dir_all(root.join(DEV_SKILL_DIR)).unwrap();
assert!(
dev_skill_install_if_wanted(&location, base.path(), home.path(), true, || {
panic!("an installed skill must not prompt")
})
.is_none(),
"installed in base={in_base} home={in_home} still produced an install"
);
}
}

/// Under `--yes`, the marker decides. The remembered no is read before the flag rather
/// than after, or a CI machine relitigates it hourly; absent one, `--yes` is the
/// codebase's existing "answered in advance". Neither case may reach the prompt.
#[test]
fn under_the_yes_flag_the_marker_decides() {
for declined in [true, false] {
let (base, home, location) = scratch();
if declined {
let marker = home.path().join(DEV_SKILL_DECLINED_MARKER);
fs::create_dir_all(marker.parent().unwrap()).unwrap();
File::create(&marker).unwrap();
}
let install =
dev_skill_install_if_wanted(&location, base.path(), home.path(), true, || {
panic!("--yes must not prompt")
});
assert_eq!(
install.is_none(),
declined,
"--yes with a recorded decline={declined} decided the wrong way"
);
}
}

/// The property the old confirmation test guarded, re-expressed against the gate that
/// replaced it, with the memory the gate added.
#[test]
fn a_declined_prompt_starts_no_install_and_is_remembered() {
let (base, home, location) = scratch();
assert!(
dev_skill_install_if_wanted(&location, base.path(), home.path(), false, || Some(false))
.is_none()
);
assert!(
home.path().join(DEV_SKILL_DECLINED_MARKER).exists(),
"a decline that is not written down is a decline that gets asked again"
);
assert!(
dev_skill_install_if_wanted(&location, base.path(), home.path(), false, || {
panic!("the recorded decline must not prompt again")
})
.is_none()
);
}

/// The install writes a directory a third party owns, and #567's own installer currently
/// writes none, so a yes not written down here is a yes asked again on every scaffold.
#[test]
fn an_accepted_prompt_installs_and_is_remembered() {
let (base, home, location) = scratch();
assert!(
dev_skill_install_if_wanted(&location, base.path(), home.path(), false, || Some(true))
.is_some()
);
assert!(
dev_skill_install_if_wanted(&location, base.path(), home.path(), false, || {
panic!("the recorded accept must not prompt again")
})
.is_some()
);
}

/// A prompt that could not be shown is not a decline, so it is not remembered as one and
/// the next scaffold still asks.
#[test]
fn an_undisplayable_prompt_installs_nothing_and_records_nothing() {
let (base, home, location) = scratch();
assert!(
dev_skill_install_if_wanted(&location, base.path(), home.path(), false, || None)
.is_none()
);
assert!(dev_skill_answer(home.path()).is_none());
}

/// The three ways this goes wrong on a real machine, in order: no Node at all, an install
/// that fails, an install that hangs. The scaffold is held by none of them.
#[cfg(unix)]
#[test]
fn no_outcome_of_the_install_reaches_the_scaffold() {
let started = Instant::now();
spawn_dev_skill_install(Command::new("surfpool-567-no-such-binary"));
spawn_dev_skill_install(sh("exit 1"));
spawn_dev_skill_install(sh("sleep 30"));
assert!(
started.elapsed() < Duration::from_secs(5),
"the scaffold waited on the install: {:?}",
started.elapsed()
);
}
}
Loading