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

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ exclude = [".github", "npm", "scripts"]
[dependencies]
clap = { version = "4", features = ["derive"] }
directories = "6"
dotenvy = "0.15.7"
serde_json = "1"
tempfile = "3"
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ run-code node@20 snippet.ts -- first --verbose

The source file is read and its contents are copied into a newly created isolated template project before execution. `run-code` does not execute inside the source file's existing project, discover that project's dependencies, or copy sibling files. Add everything the snippet needs with `--package`; arguments after `--` are passed to the snippet.

### Working directory and environment

```bash
run-code node@20 --cwd ./fixtures --env-file ./snippet.env snippet.ts
```

`--cwd` changes the working directory seen by the final snippet process. Template initialization and dependency installation still happen inside the isolated temporary project. `--env-file` loads dotenv-compatible variables for the final launch and snippet process; it does not modify the current shell or earlier setup and dependency-installation steps, and loaded values are omitted from displayed commands. Both paths are resolved from the directory where `run-code` was invoked.

### TypeScript

```bash
Expand Down Expand Up @@ -139,7 +147,7 @@ Several related tools solve parts of this problem, but none matched the combinat

## Security

`run-code` provides environment and dependency isolation; it is not a security sandbox. Snippets and third-party dependencies run with the current user's permissions and may access local files, the network, environment variables, and credentials.
`run-code` provides environment and dependency isolation; it is not a security sandbox. Snippets and third-party dependencies run with the current user's permissions and may access local files, the network, environment variables, and credentials. Variables loaded with `--env-file` are deliberately available to the snippet, so do not pass secrets to untrusted code.

Dependency installation may execute npm lifecycle scripts, Python build backends, Cargo `build.rs` scripts, or other ecosystem-specific build code. Run only trusted code and dependencies. Inspect unfamiliar packages before use, pin versions in sensitive environments, and avoid exposing unnecessary secrets. `--clean` removes only the temporary project; it cannot undo system or network side effects, and package-manager download caches remain in place.

Expand All @@ -157,6 +165,8 @@ run-code skill
- `FILE`: Read a source file and copy its contents into a new isolated template project. The file's existing project and sibling files are not used. When omitted, source code is read from stdin.
- `ARG`: Pass arguments after `--` to the snippet process. This also works with stdin input.
- `-p, --package SPEC`: Add a temporary dependency. Repeat the option to install multiple packages. Specs follow each ecosystem: Python uses `NAME==VERSION`; Node, Rust, Go, and .NET use `NAME@VERSION`. Rust also supports `NAME[@VERSION][FEATURE,...]`, such as `'tokio@1[full]'`.
- `--cwd DIR`: Set the final snippet process's working directory. Template setup and dependency installation remain isolated from this directory.
- `--env-file FILE`: Load dotenv-compatible variables for the final launch and snippet process. Values override inherited variables with the same name and are not printed in displayed commands; runner-owned isolation variables take precedence.
- `--commonjs`: Run Node code as CommonJS. The default is ESM with top-level `await` support.
- `--clean`: Delete the generated project after execution. Without this option, the project is retained and its path appears in the displayed command.
- `--quiet`: Hide project setup, dependency installation, and command display; print only stdout/stderr from the final code process.
Expand Down
12 changes: 11 additions & 1 deletion README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ run-code node@20 snippet.ts -- first --verbose

执行前,`run-code` 会读取源文件,并将内容复制到新建的隔离模板项目中。它不会在源文件所属的已有工程里运行,不会读取该工程的依赖,也不会复制同目录的其他文件。代码片段需要的依赖应通过 `--package` 明确添加;`--` 后的参数会传给代码进程。

### 工作目录与环境变量

```bash
run-code node@20 --cwd ./fixtures --env-file ./snippet.env snippet.ts
```

`--cwd` 设置最终代码进程看到的工作目录;模板初始化和依赖安装仍然在隔离的临时项目中完成。`--env-file` 按 dotenv 语法为最终启动命令和代码进程加载变量,不修改当前 shell,也不会用于之前的初始化及依赖安装步骤;加载的值不会显示在输出的命令中。两个路径都基于调用 `run-code` 时所在的目录解析。

### TypeScript

```bash
Expand Down Expand Up @@ -139,7 +147,7 @@ Codex 会自动发现这些目录中的 skill;详细约定见 [Codex Skills

## 安全

`run-code` 提供的是环境和依赖隔离,不是安全沙箱。输入的代码和第三方依赖都以当前用户权限运行,可以访问本机文件、网络、环境变量和凭据。
`run-code` 提供的是环境和依赖隔离,不是安全沙箱。输入的代码和第三方依赖都以当前用户权限运行,可以访问本机文件、网络、环境变量和凭据。`--env-file` 加载的变量会明确提供给代码片段,不要把密钥传给不可信代码。

依赖安装还可能执行 npm lifecycle scripts、Python build backend、Cargo `build.rs` 或其他生态的构建代码。只运行可信代码和依赖;使用陌生包前先检查官方文档与源码,敏感环境中固定版本,并避免暴露不必要的密钥。`--clean` 只删除临时项目,不会撤销代码已经产生的系统或网络副作用;各包管理器的下载缓存会继续保留。

Expand All @@ -157,6 +165,8 @@ run-code skill
- `FILE`:读取源文件,并将内容复制进新的隔离模板项目;不会使用文件所在的已有工程或同目录文件。省略时从 stdin 读取代码。
- `ARG`:在 `--` 后提供并传给代码进程;stdin 输入同样可以传参。
- `-p, --package SPEC`:添加临时依赖,可重复使用以安装多个包。依赖格式遵循对应生态;Python 版本使用 `NAME==VERSION`,Node、Rust、Go 和 .NET 使用 `NAME@VERSION`。Rust 还支持 `NAME[@VERSION][FEATURE,...]`,例如 `'tokio@1[full]'`。
- `--cwd DIR`:设置最终代码进程的工作目录;模板初始化和依赖安装仍与该目录隔离。
- `--env-file FILE`:按 dotenv 语法为最终启动命令和代码进程加载变量;同名变量会覆盖继承的环境变量,值不会显示在输出命令中,但 runner 自身用于隔离的变量优先。
- `--commonjs`:让 Node 以 CommonJS 方式运行;默认使用支持顶层 `await` 的 ESM。
- `--clean`:运行结束后删除临时项目;不指定时保留项目,默认输出的执行命令中会包含项目路径。
- `--quiet`:隐藏项目初始化、依赖安装及命令本身,只输出最终代码进程的 stdout/stderr。
Expand Down
6 changes: 6 additions & 0 deletions npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ Or copy a source snippet into an isolated template project and pass arguments:
npx @timzhong2000/run-code node@20 snippet.ts -- first --verbose
```

Set the snippet's working directory and load dotenv variables without changing where the isolated template and dependencies are prepared:

```bash
npx @timzhong2000/run-code node@20 --cwd ./fixtures --env-file ./snippet.env snippet.ts
```

The package selects the bundled binary for the current operating system and CPU architecture.
10 changes: 7 additions & 3 deletions skills/run-code-snippet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@ description: 临时快捷运行一段 Python、TypeScript、JavaScript、Rust、
在 Bash 或 Zsh 中使用 quoted heredoc,将代码原样交给 stdin:

```bash
run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--commonjs] [--clean] [--quiet] <<'LANG'
run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--cwd DIR] [--env-file FILE] [--commonjs] [--clean] [--quiet] <<'LANG'
CODE
LANG
```

也可以读取已有代码片段文件,并在 `--` 后传入参数:

```bash
run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--commonjs] [--clean] [--quiet] FILE [-- ARG ...]
run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--cwd DIR] [--env-file FILE] [--commonjs] [--clean] [--quiet] FILE [-- ARG ...]
```

文件模式只读取 `FILE` 的内容,再将其复制进新建的隔离模板项目运行;不会在 `FILE` 所属的已有工程中执行,不会读取该工程的依赖,也不会复制相邻文件。缺少的依赖仍用 `--package` 明确添加。

代码需要从特定目录读取临时数据时使用 `--cwd DIR`;它只改变最终代码进程的工作目录,模板和依赖安装仍保持隔离。需要环境变量时使用 dotenv 格式的 `--env-file FILE`;变量用于最终启动命令和代码进程,不修改当前 shell,也不会用于之前的初始化及依赖安装步骤,值不会显示在命令输出中。两个路径均相对于调用命令时的目录解析。不要把密钥提供给不可信代码。

Fish 不支持 heredoc;用 `printf` 将每行代码送入 stdin:

```fish
Expand All @@ -35,7 +37,7 @@ printf '%s\n' \
```powershell
@'
CODE
'@ | run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--commonjs] [--clean] [--quiet]
'@ | run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--cwd DIR] [--env-file FILE] [--commonjs] [--clean] [--quiet]
```

使用 `python`、`node`、`rust`、`go` 或 `dotnet`;`javascript` 和 `typescript` 是 `node` 的别名,`csharp` 和 `cs` 是 `dotnet` 的别名。省略版本时使用内置的最新稳定版本。Python 依赖版本使用 `NAME==VERSION`;Rust 依赖可用 `NAME[@VERSION][FEATURE,...]` 指定 Cargo features;.NET 依赖使用 NuGet 的 `NAME[@VERSION]`。Node 默认以 ESM 统一运行 JavaScript/TypeScript,仅在代码明确依赖 CommonJS 时添加 `--commonjs`。C# 使用 .NET 10+ file-based app。stdin 模式下,Python 和 Node 未指定 `--package` 时直接执行;文件模式始终创建隔离模板项目。需要项目时默认保留;仅在明确只需一次结果时添加 `--clean`,仅需代码本身的输出时添加 `--quiet`。直接调用工具,不自行创建项目或检查运行环境。
Expand All @@ -45,6 +47,8 @@ CODE
```bash
run-code node@20 snippet.ts -- first --verbose

run-code python@3.14 --cwd ./fixtures --env-file ./snippet.env snippet.py

run-code node@20 --package zod@4 --clean <<'TS'
import { z } from "zod";
console.log(z.object({ id: z.number() }).parse({ id: 1 }));
Expand Down
27 changes: 26 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ pub struct Cli {
#[arg(last = true, value_name = "ARG")]
pub args: Vec<String>,

/// Working directory for the final code process
#[arg(long, value_name = "DIR")]
pub cwd: Option<PathBuf>,

/// Load environment variables for the final code process
#[arg(long, value_name = "FILE")]
pub env_file: Option<PathBuf>,

/// Dependency specification; Python supports NAME==VERSION
#[arg(short = 'p', long = "package", value_name = "SPEC")]
pub packages: Vec<String>,
Expand Down Expand Up @@ -140,6 +148,8 @@ impl Cli {
|| self.quiet
|| self.source.is_some()
|| !self.args.is_empty()
|| self.cwd.is_some()
|| self.env_file.is_some()
{
Some("the skill command does not accept execution arguments".into())
} else {
Expand All @@ -164,7 +174,7 @@ mod tests {
.get_arguments()
.filter(|arg| arg.get_id() != "help" && arg.get_id() != "version")
.count();
assert_eq!(visible, 7);
assert_eq!(visible, 9);
}

#[test]
Expand All @@ -190,6 +200,21 @@ mod tests {
assert_eq!(cli.args, ["first"]);
}

#[test]
fn execution_context_options_accept_paths() {
let cli = Cli::try_parse_from([
"run-code",
"python",
"--cwd",
"data",
"--env-file",
"snippet.env",
])
.unwrap();
assert_eq!(cli.cwd, Some(PathBuf::from("data")));
assert_eq!(cli.env_file, Some(PathBuf::from("snippet.env")));
}

#[test]
fn skill_is_a_command_without_a_toolchain() {
let cli = Cli::try_parse_from(["run-code", "skill"]).unwrap();
Expand Down
130 changes: 130 additions & 0 deletions src/execution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
use crate::cli::Cli;
use std::path::{Path, PathBuf};

#[derive(Debug, Default)]
pub struct ExecutionContext {
working_directory: Option<PathBuf>,
environment: Vec<(String, String)>,
}

impl ExecutionContext {
pub fn load(cli: &Cli) -> Result<Self, String> {
let working_directory = cli.cwd.as_deref().map(canonical_directory).transpose()?;
let environment = cli
.env_file
.as_deref()
.map(read_env_file)
.transpose()?
.unwrap_or_default();
Ok(Self {
working_directory,
environment,
})
}

pub fn cwd_or<'a>(&'a self, fallback: &'a Path) -> &'a Path {
self.working_directory.as_deref().unwrap_or(fallback)
}

pub fn has_custom_cwd(&self) -> bool {
self.working_directory.is_some()
}

pub fn environment(&self) -> &[(String, String)] {
&self.environment
}
}

fn canonical_directory(path: &Path) -> Result<PathBuf, String> {
let resolved = path.canonicalize().map_err(|error| {
format!(
"failed to resolve working directory {}: {error}",
path.display()
)
})?;
if !resolved.is_dir() {
return Err(format!(
"working directory is not a directory: {}",
path.display()
));
}
Ok(resolved)
}

fn read_env_file(path: &Path) -> Result<Vec<(String, String)>, String> {
let entries = dotenvy::from_path_iter(path).map_err(|error| {
format!(
"failed to read environment file {}: {error}",
path.display()
)
})?;
entries
.map(|entry| {
entry.map_err(|error| {
format!(
"failed to parse environment file {}: {error}",
path.display()
)
})
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::fs;

#[test]
fn loads_quoted_and_escaped_environment_values_without_mutating_process() {
let process_value_before = std::env::var_os("RUN_CODE_CONTEXT_TEST");
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join("snippet.env");
fs::write(
&env_file,
"RUN_CODE_CONTEXT_TEST='hello world'\nESCAPED=hello\\ world\n",
)
.unwrap();
let cli = Cli::try_parse_from([
"run-code",
"python",
"--cwd",
dir.path().to_str().unwrap(),
"--env-file",
env_file.to_str().unwrap(),
])
.unwrap();

let context = ExecutionContext::load(&cli).unwrap();
assert_eq!(
context.cwd_or(Path::new("fallback")),
dir.path().canonicalize().unwrap()
);
assert_eq!(
context.environment(),
[
("RUN_CODE_CONTEXT_TEST".into(), "hello world".into()),
("ESCAPED".into(), "hello world".into())
]
);
assert_eq!(
std::env::var_os("RUN_CODE_CONTEXT_TEST"),
process_value_before
);
}

#[test]
fn rejects_missing_or_non_directory_working_directories() {
let missing =
Cli::try_parse_from(["run-code", "python", "--cwd", "run-code-missing-directory"])
.unwrap();
assert!(ExecutionContext::load(&missing).is_err());

let file = tempfile::NamedTempFile::new().unwrap();
let not_directory =
Cli::try_parse_from(["run-code", "python", "--cwd", file.path().to_str().unwrap()])
.unwrap();
assert!(ExecutionContext::load(&not_directory).is_err());
}
}
9 changes: 8 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod cli;
mod execution;
mod process;
mod runner;
mod skill;
Expand All @@ -7,6 +8,7 @@ mod util;

use clap::{Parser, error::ErrorKind};
use cli::{Cli, Command};
use execution::ExecutionContext;

fn main() {
let cli = match Cli::try_parse() {
Expand All @@ -28,6 +30,11 @@ fn main() {
return;
}

let execution = match ExecutionContext::load(&cli) {
Ok(execution) => execution,
Err(error) => return exit_with_error(&error, 2),
};

let code = match source::read(cli.source.as_deref()) {
Ok(code) => code,
Err(error) => return exit_with_error(&error, 1),
Expand All @@ -41,7 +48,7 @@ fn main() {
return exit_with_error(&format!("{input} did not contain source code"), 2);
}

match runner::run_snippet(&cli, &code) {
match runner::run_snippet(&cli, &execution, &code) {
Ok(exit_code) => exit(exit_code),
Err(error) => {
eprintln!("run-code: {}", error.message);
Expand Down
Loading