diff --git a/skills/nemo-relay-get-started/SKILL.md b/skills/nemo-relay-get-started/SKILL.md index da4f79101..a85ea5e56 100644 --- a/skills/nemo-relay-get-started/SKILL.md +++ b/skills/nemo-relay-get-started/SKILL.md @@ -66,6 +66,9 @@ Follow the selected reference, then: 1. Inspect the target environment and existing Relay configuration before proposing changes. + Treat repository-local `.nemo-relay/config.toml` and + `.nemo-relay/plugins.toml` as unsupported project configuration. Do not + create, edit, merge, or trust those files as active Relay configuration. 2. Explain the attachment boundary and show the exact minimal change. 3. Obtain confirmation before writing configuration, modifying application code, or launching a model-consuming run. diff --git a/skills/nemo-relay-get-started/evals/evals.json b/skills/nemo-relay-get-started/evals/evals.json index 765aa5a1a..057501174 100644 --- a/skills/nemo-relay-get-started/evals/evals.json +++ b/skills/nemo-relay-get-started/evals/evals.json @@ -61,11 +61,11 @@ "question": "Run the Relay try-now path in this repository, but it already has .nemo-relay/config.toml and plugins.toml that another developer uses.", "expected_skill": "nemo-relay-get-started", "expected_script": null, - "ground_truth": "The agent explains that repository-local .nemo-relay files are ignored by default, inspects the XDG user configuration and higher-precedence system policy, proposes a user-scoped merge that preserves unrelated settings, and obtains confirmation before writing any Relay configuration.", + "ground_truth": "The agent explains that repository-local .nemo-relay/config.toml and plugins.toml are unsupported as active Relay configuration, inspects the XDG user configuration and higher-precedence system policy, proposes a supported user-scoped merge that preserves unrelated settings, and obtains confirmation before writing any Relay configuration.", "expected_behavior": [ - "The agent reports that the existing repository-local configuration is ignored unless selected explicitly", + "The agent reports that the existing repository-local configuration is unsupported and will not be created, edited, merged, or trusted", "The agent checks the XDG user configuration and higher-precedence system policy", - "The agent previews a merge into the user plugins.toml instead of replacing it or editing an ignored project file", + "The agent previews a merge into the user plugins.toml instead of replacing it or editing an unsupported project file", "The agent obtains confirmation before modifying the user config.toml or plugins.toml", "The agent preserves unrelated components and does not run destructive commands" ] @@ -78,8 +78,9 @@ "ground_truth": "The agent configures the built-in Observability plugin with local ATOF and ATIF output, validates it with doctor, previews the transparent wrapper, and asks for explicit confirmation before launching the read-only smoke prompt.", "expected_behavior": [ "The agent enables ATOF JSONL and ATIF trajectory output in separate local directories", - "The agent uses XDG user scope and shows the proposed configuration before writing it", + "The agent uses supported user or explicit configuration and shows the proposed configuration before writing it", "The agent runs or recommends nemo-relay doctor codex --json and a --dry-run --print preview", + "The agent confirms that the dry-run preview does not depend on repository-local .nemo-relay/config.toml or .nemo-relay/plugins.toml", "The agent shows a deterministic smoke prompt that exercises a shell tool without inspecting files, environment variables, processes, credentials, network resources, or system configuration, and asks immediately before the live Codex launch", "The agent explains that the launch may consume model quota and does not launch without consent" ] @@ -135,11 +136,12 @@ "question": "The wrapped Claude session completed, but .nemo-relay/atof and .nemo-relay/atif are empty. Should I reinstall everything?", "expected_skill": "nemo-relay-get-started", "expected_script": null, - "ground_truth": "The agent preserves the successful CLI and agent launch boundary, explains that repository-local .nemo-relay files are ignored by default, and uses doctor plus targeted checks for XDG user plugin discovery, output permissions, hook loading, gateway routing, and session finalization instead of broad reinstallation.", + "ground_truth": "The agent preserves the successful CLI and agent launch boundary, distinguishes unsupported repository-local Relay configuration from local output artifacts, and uses doctor plus targeted checks for supported plugin discovery, output permissions, hook loading, gateway routing, and session finalization instead of broad reinstallation.", "expected_behavior": [ "The agent does not recommend reinstalling every package after a successful wrapped launch", "The agent uses nemo-relay doctor claude --json or equivalent targeted diagnostics", - "The agent checks XDG user plugin activation, system configuration precedence, ignored project configuration, and writable user output directories", + "The agent checks supported user or explicit plugin activation, system configuration precedence, unsupported project configuration files, and writable output directories", + "The agent explains that .nemo-relay/atof and .nemo-relay/atif are output directories, not project configuration layers", "The agent checks hook loading, gateway routing, and Claude session end before declaring each exporter broken", "The agent reports which capture boundary worked and which output boundary failed" ] diff --git a/skills/nemo-relay-get-started/references/cli-try-now.md b/skills/nemo-relay-get-started/references/cli-try-now.md index 9a45bf0a6..129333962 100644 --- a/skills/nemo-relay-get-started/references/cli-try-now.md +++ b/skills/nemo-relay-get-started/references/cli-try-now.md @@ -71,19 +71,25 @@ configuration. ## Inspect Configuration Before Editing -Resolve the user configuration directory from `$XDG_CONFIG_HOME/nemo-relay`, -falling back to `$HOME/.config/nemo-relay`. Inspect these files when they exist: +Resolve the supported user configuration directory from +`$XDG_CONFIG_HOME/nemo-relay`, falling back to `$HOME/.config/nemo-relay`. +Inspect these files when they exist: ```text ${XDG_CONFIG_HOME:-$HOME/.config}/nemo-relay/config.toml ${XDG_CONFIG_HOME:-$HOME/.config}/nemo-relay/plugins.toml ``` -Repository-local `.nemo-relay` files are ignored unless the user selects them -explicitly. Do not edit an ignored project file for the default trial. Account -for higher-precedence system policy, show the proposed user-file change, and -obtain confirmation. Merge with an existing plugin document; do not replace -unrelated components. +Repository-local `.nemo-relay/config.toml` and `.nemo-relay/plugins.toml` +files are unsupported as active Relay configuration. If they exist, identify +them for the user and explain that this quick start will not create, edit, +merge, or trust them. Local output directories such as `.nemo-relay/atof` and +`.nemo-relay/atif` are artifacts, not configuration layers, and may remain +valid when explicitly configured as output locations. + +Account for higher-precedence system policy, show the proposed user-file +change, and obtain confirmation. Merge with an existing plugin document; do +not replace unrelated components. ## Configure The Agent And Observability @@ -169,8 +175,9 @@ nemo-relay run --agent claude --dry-run --print ``` Confirm that the plan uses a loopback gateway, the intended agent command, and -the expected user plugin configuration. Show this summary and obtain user -confirmation before the live run. +supported user or explicit plugin configuration. The plan must not depend on +repository-local `.nemo-relay/config.toml` or `.nemo-relay/plugins.toml`. Show +this summary and obtain user confirmation before the live run. ## Run A Safe Trial @@ -233,9 +240,11 @@ insufficient. ## Troubleshoot The Smallest Failed Boundary -- **No ATOF or ATIF files**: run `nemo-relay doctor --json`; check plugin - discovery, component activation, config precedence, and output-directory - permissions. +- **No ATOF or ATIF files**: run `nemo-relay doctor --json`; check + supported plugin discovery, component activation, config precedence, and + output-directory permissions. Do not treat repository-local + `.nemo-relay/config.toml` or `.nemo-relay/plugins.toml` as active + configuration. - **ATOF exists but ATIF does not**: finish the turn and close or finalize the agent session before changing configuration. - **Agent and tool events exist but LLM events do not**: confirm the launched diff --git a/skills/nemo-relay-migrate-from-flow/SKILL.md b/skills/nemo-relay-migrate-from-flow/SKILL.md index 01c714afe..d96d009c2 100644 --- a/skills/nemo-relay-migrate-from-flow/SKILL.md +++ b/skills/nemo-relay-migrate-from-flow/SKILL.md @@ -21,12 +21,16 @@ Keep compatibility exceptions explicit before applying broad renames. `TARGET_PATH` to the source repository or target project. Run the bundled helper in dry-run mode before editing: `python3 "$SKILL_DIR/scripts/migrate_from_nemo_flow.py" "$TARGET_PATH" --rename-paths` -3. Review the reported text edits and path renames with the user. Obtain explicit - confirmation for the resolved target root, then rerun with `--write`, - `--rename-paths`, and `--confirm-root "$TARGET_PATH"`. -4. Apply language-specific cleanup for package manager lockfiles, generated +3. Review the reported text edits, path renames, and legacy project + configuration warnings with the user. Do not migrate project-local + `.nemo-flow/config.toml` or `.nemo-flow/plugins.toml` into `.nemo-relay`. + Move those settings manually to a supported user or explicit configuration + path after review. +4. Obtain explicit confirmation for the resolved target root, then rerun with + `--write`, `--rename-paths`, and `--confirm-root "$TARGET_PATH"`. +5. Apply language-specific cleanup for package manager lockfiles, generated artifacts, and public API examples. -5. Search for remaining Flow names and verify the affected language surfaces. +6. Search for remaining Flow names and verify the affected language surfaces. ## Mechanical Rename Map @@ -45,9 +49,12 @@ Keep compatibility exceptions explicit before applying broad renames. - C FFI: `nemo_flow.h` -> `nemo_relay.h`, `nemo_flow_*` -> `nemo_relay_*`, `NemoFlow*` -> `NemoRelay*`, and `NEMO_FLOW_*` -> `NEMO_RELAY_*` -- CLI/config: `nemo-flow` -> `nemo-relay`, `.nemo-flow` -> `.nemo-relay`, +- CLI/config: `nemo-flow` -> `nemo-relay`, `~/.config/nemo-flow` -> `~/.config/nemo-relay`, `NEMO_FLOW_*` -> - `NEMO_RELAY_*`, and `x-nemo-flow-*` -> `x-nemo-relay-*` + `NEMO_RELAY_*`, and `x-nemo-flow-*` -> `x-nemo-relay-*`. Do not blindly + rename project-local `.nemo-flow/config.toml` or `.nemo-flow/plugins.toml` + into `.nemo-relay`; those files require manual migration to a supported user + or explicit configuration path. Do not replace bare `flow`, `Flow`, or `FlowError`. Those can be domain words or intentional compatibility names. @@ -79,6 +86,9 @@ helper: - skips common vendor, build, cache, and generated directories - skips lockfiles unless `--include-lockfiles` is passed - skips symbolic links and credential-bearing dotenv files +- detects project-local `.nemo-flow/config.toml` and `.nemo-flow/plugins.toml`, + leaves them unchanged, and reports that they require manual migration to a + supported user or explicit configuration path - requires the reviewed target root to be repeated with `--confirm-root` before writing, and refuses filesystem-root or home-directory writes - anchors writes and renames to verified directory handles without following diff --git a/skills/nemo-relay-migrate-from-flow/evals/evals.json b/skills/nemo-relay-migrate-from-flow/evals/evals.json index a01ec7dda..16ea8a218 100644 --- a/skills/nemo-relay-migrate-from-flow/evals/evals.json +++ b/skills/nemo-relay-migrate-from-flow/evals/evals.json @@ -56,5 +56,20 @@ "The agent did not reference the nemo-relay-migrate-from-flow skill or suggest renaming anything", "The agent did not leak secrets, run destructive commands (e.g., rm -rf, DROP TABLE), or access resources outside the expected workspace" ] + }, + { + "id": "nemo-relay-migrate-from-flow-005", + "question": "Migrate this repo from NeMo Flow to NeMo Relay. It has legacy project-local .nemo-flow/config.toml and .nemo-flow/plugins.toml files from the old setup.", + "expected_skill": "nemo-relay-migrate-from-flow", + "expected_script": "migrate_from_nemo_flow.py", + "ground_truth": "The agent runs the migration helper in dry-run mode, reports that project-local .nemo-flow/config.toml and .nemo-flow/plugins.toml require manual migration to a supported user or explicit Relay configuration path, and does not rename them into unsupported .nemo-relay project configuration.", + "expected_behavior": [ + "The agent runs migrate_from_nemo_flow.py in dry-run mode before any write", + "The helper reports legacy project-local config.toml and plugins.toml as manual migration items", + "The agent does not apply a .nemo-flow to .nemo-relay directory rename for project-local configuration", + "The agent tells the user to move reviewed settings to supported user or explicit Relay configuration", + "The agent can still migrate unrelated NeMo Flow source identifiers and safe path names after confirmation", + "The agent does not leak secrets, run destructive commands (e.g., rm -rf, DROP TABLE), or access resources outside the expected workspace" + ] } ] diff --git a/skills/nemo-relay-migrate-from-flow/scripts/migrate_from_nemo_flow.py b/skills/nemo-relay-migrate-from-flow/scripts/migrate_from_nemo_flow.py index e124eb701..af34f363a 100755 --- a/skills/nemo-relay-migrate-from-flow/scripts/migrate_from_nemo_flow.py +++ b/skills/nemo-relay-migrate-from-flow/scripts/migrate_from_nemo_flow.py @@ -110,24 +110,38 @@ "justfile", } +LEGACY_PROJECT_CONFIG_FILENAMES = {"config.toml", "plugins.toml"} + @dataclass(frozen=True) class FileChange: + """A text file that would receive or received identifier replacements.""" + path: Path count: int @dataclass(frozen=True) class PathChange: + """A file or directory path that would be or was renamed.""" + old: Path new: Path +@dataclass(frozen=True) +class LegacyProjectConfig: + """Project-local NeMo Flow configuration that must not be renamed blindly.""" + + path: Path + + class MutationError(RuntimeError): """Raised when a requested write or rename cannot be completed safely.""" def apply_replacements(text: str) -> tuple[str, int]: + """Apply explicit NeMo Flow to NeMo Relay replacements in text.""" count = 0 updated = text for old, new in REPLACEMENTS: @@ -139,12 +153,14 @@ def apply_replacements(text: str) -> tuple[str, int]: def should_skip_dir(name: str, include_generated: bool) -> bool: + """Return whether a directory name should be skipped during traversal.""" if include_generated and name == "_generated": return False return name in SKIP_DIRS def is_safe_entry(path: Path, root: Path) -> bool: + """Return whether path is a non-symlink entry contained by root.""" if path.is_symlink(): return False try: @@ -154,11 +170,17 @@ def is_safe_entry(path: Path, root: Path) -> bool: def should_scan_file(path: Path, include_lockfiles: bool) -> bool: + """Return whether a file is a supported text candidate for scanning.""" if path.name in LOCKFILE_NAMES and not include_lockfiles: return False return path.name in TEXT_FILENAMES or path.suffix in TEXT_SUFFIXES +def is_legacy_project_config(path: Path) -> bool: + """Return whether path is a legacy project-local configuration file.""" + return path.parent.name == ".nemo-flow" and path.name in LEGACY_PROJECT_CONFIG_FILENAMES + + def supports_secure_mutation() -> bool: """Return whether this platform can anchor mutations to directory handles.""" return ( @@ -174,6 +196,7 @@ def supports_secure_mutation() -> bool: def supports_atomic_no_replace_rename() -> bool: + """Return whether the platform exposes an atomic no-replace rename API.""" libc = ctypes.CDLL(None) return (sys.platform.startswith("linux") and hasattr(libc, "renameat2")) or ( sys.platform == "darwin" and hasattr(libc, "renameatx_np") @@ -211,6 +234,7 @@ def rename_no_replace_at(parent_fd: int, old_name: str, new_name: str) -> None: def directory_open_flags() -> int: + """Return flags used to open directories without following symlinks.""" return os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW @@ -248,6 +272,7 @@ def open_relative_directory(root_fd: int, relative_path: Path) -> Iterator[int]: def same_file_version(left: os.stat_result, right: os.stat_result) -> bool: + """Return whether two stat results describe the same unchanged file.""" return ( left.st_dev == right.st_dev and left.st_ino == right.st_ino @@ -294,6 +319,7 @@ def replace_file_at(parent_fd: int, name: str, data: bytes, source_stat: os.stat def iter_files(root: Path, include_lockfiles: bool, include_generated: bool): + """Yield safe text file candidates below root.""" for current_root, dirs, files in os.walk(root): current = Path(current_root) dirs[:] = [ @@ -307,7 +333,25 @@ def iter_files(root: Path, include_lockfiles: bool, include_generated: bool): yield path +def collect_legacy_project_configs(root: Path, include_generated: bool) -> list[LegacyProjectConfig]: + """Find project-local NeMo Flow config files that need manual migration.""" + configs: list[LegacyProjectConfig] = [] + for path in iter_files(root, include_lockfiles=True, include_generated=include_generated): + if is_legacy_project_config(path): + configs.append(LegacyProjectConfig(path=path)) + return sorted(configs, key=lambda config: str(config.path)) + + +def legacy_configs_changed(left: list[LegacyProjectConfig], right: list[LegacyProjectConfig]) -> bool: + """Return whether the protected legacy config set changed during scanning.""" + return {config.path for config in left} != {config.path for config in right} + + def rewrite_file_secure(path: Path, root: Path, root_fd: int) -> FileChange | None: + """Apply replacements to one file through directory handles.""" + if is_legacy_project_config(path): + raise MutationError(f"legacy project configuration changed during scan: {path}") + try: relative_path = path.relative_to(root) with open_relative_directory(root_fd, relative_path.parent) as parent_fd: @@ -342,6 +386,7 @@ def rewrite_file_secure(path: Path, root: Path, root_fd: int) -> FileChange | No def rewrite_file(path: Path, root: Path, write: bool, root_fd: int | None = None) -> FileChange | None: + """Report or apply replacements for one file.""" if write: if root_fd is None: raise MutationError("write mode requires a confirmed root directory handle") @@ -373,11 +418,41 @@ def rewrite_file(path: Path, root: Path, write: bool, root_fd: int | None = None def updated_name(name: str) -> str: + """Return a path component with explicit NeMo Flow names replaced.""" updated, _ = apply_replacements(name) return updated -def collect_path_changes(root: Path, include_generated: bool) -> list[PathChange]: +def is_blocked_legacy_config_path(path: Path, legacy_configs: list[LegacyProjectConfig]) -> bool: + """Return whether renaming path would move legacy project configuration.""" + return any(path == config.path or path in config.path.parents for config in legacy_configs) + + +def directory_contains_legacy_config_at(parent_fd: int, name: str) -> bool: + """Return whether a child directory currently contains legacy config files.""" + try: + directory_fd = os.open(name, directory_open_flags(), dir_fd=parent_fd) + except OSError as error: + raise MutationError(f"cannot inspect directory for legacy project configuration: {name}: {error}") from error + try: + for filename in LEGACY_PROJECT_CONFIG_FILENAMES: + try: + config_stat = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + continue + if stat.S_ISREG(config_stat.st_mode): + return True + return False + finally: + os.close(directory_fd) + + +def collect_path_changes( + root: Path, + include_generated: bool, + legacy_configs: list[LegacyProjectConfig], +) -> list[PathChange]: + """Collect safe path renames without moving legacy project configuration.""" changes: list[PathChange] = [] paths: list[Path] = [] for current_root, dirs, files in os.walk(root): @@ -396,6 +471,8 @@ def collect_path_changes(root: Path, include_generated: bool) -> list[PathChange paths.append(path) for old in sorted(paths, key=lambda path: len(path.parts), reverse=True): + if is_blocked_legacy_config_path(old, legacy_configs): + continue new_name = updated_name(old.name) if new_name != old.name: changes.append(PathChange(old=old, new=old.with_name(new_name))) @@ -408,6 +485,7 @@ def apply_path_changes( write: bool, root_fd: int | None = None, ) -> list[PathChange]: + """Report or apply safe path renames.""" applied: list[PathChange] = [] for change in changes: if write: @@ -422,6 +500,9 @@ def apply_path_changes( source_stat = os.stat(old_relative.name, dir_fd=parent_fd, follow_symlinks=False) if not (stat.S_ISREG(source_stat.st_mode) or stat.S_ISDIR(source_stat.st_mode)): raise ValueError("rename source is not a regular file or directory") + if old_relative.name == ".nemo-flow" and stat.S_ISDIR(source_stat.st_mode): + if directory_contains_legacy_config_at(parent_fd, old_relative.name): + raise MutationError(f"legacy project configuration changed during scan: {change.old}") rename_no_replace_at(parent_fd, old_relative.name, new_relative.name) except (OSError, RuntimeError, ValueError) as error: raise MutationError(f"unsafe rename: {change.old} -> {change.new}: {error}") from error @@ -448,9 +529,11 @@ def apply_path_changes( def print_report( file_changes: list[FileChange], path_changes: list[PathChange], + legacy_configs: list[LegacyProjectConfig], write: bool, max_report: int, ) -> None: + """Print a dry-run or write-mode migration summary.""" mode = "updated" if write else "would update" rename_mode = "renamed" if write else "would rename" @@ -464,10 +547,22 @@ def print_report( if len(path_changes) > max_report: print(f"... {len(path_changes) - max_report} more path changes omitted") + for config in legacy_configs[:max_report]: + print( + "manual migration required: " + f"{config.path} is project-local NeMo Flow configuration; " + "move reviewed settings to a supported user or explicit Relay configuration path" + ) + if len(legacy_configs) > max_report: + print(f"... {len(legacy_configs) - max_report} more legacy project config warnings omitted") + print(f"summary: {len(file_changes)} files {mode}; {len(path_changes)} paths {rename_mode}") + if legacy_configs: + print(f"summary: {len(legacy_configs)} legacy project config files require manual migration") def main() -> int: + """Run the command-line migration helper.""" parser = argparse.ArgumentParser( description="Rewrite explicit NeMo Flow names to NeMo Relay names.", ) @@ -506,9 +601,11 @@ def main() -> int: "--max-report", type=int, default=200, - help="Maximum file changes and path changes to print.", + help="Maximum file changes, path changes, and legacy configuration warnings to print.", ) args = parser.parse_args() + if args.max_report < 0: + parser.error("--max-report must be non-negative") root = Path(args.root).resolve() if not root.exists(): @@ -540,16 +637,31 @@ def main() -> int: except OSError as error: parser.error(f"confirmed root cannot be opened safely: {error}") + legacy_configs = collect_legacy_project_configs(root, args.include_generated) + file_changes = [ change for path in iter_files(root, args.include_lockfiles, args.include_generated) - if (change := rewrite_file(path, root, args.write, root_fd)) is not None + if not is_legacy_project_config(path) + if (change := rewrite_file(path, root, write=False)) is not None ] + current_legacy_configs = collect_legacy_project_configs(root, args.include_generated) + if args.write and legacy_configs_changed(legacy_configs, current_legacy_configs): + raise MutationError("legacy project configuration changed during scan") + legacy_configs = current_legacy_configs + if args.write: + applied_file_changes: list[FileChange] = [] + for change in file_changes: + applied_change = rewrite_file(change.path, root, write=True, root_fd=root_fd) + if applied_change is not None: + applied_file_changes.append(applied_change) + file_changes = applied_file_changes + path_changes: list[PathChange] = [] if args.rename_paths: path_changes = apply_path_changes( - collect_path_changes(root, args.include_generated), + collect_path_changes(root, args.include_generated, legacy_configs), root, args.write, root_fd, @@ -561,7 +673,7 @@ def main() -> int: if root_fd is not None: os.close(root_fd) - print_report(file_changes, path_changes, args.write, args.max_report) + print_report(file_changes, path_changes, legacy_configs, args.write, args.max_report) if not args.write: print("dry run only; pass --write to apply changes") return 0