Skip to content

Commit 1f7ce52

Browse files
committed
feat: agent user with sudo for apt-install, run all commands as non-root agent
- Dockerfile: pre-install common runtimes (python, node, go, java, ruby, rust, build tools) - Dockerfile: create 'agent' user with passwordless sudo for apt-get - executor.rs: run install/agent/test commands as 'agent' user via sudo -u - executor.rs: prepare_install_command adds sudo + DEBIAN_FRONTEND for apt commands - executor.rs: chmod repo dirs writable for agent user
1 parent 3855f2d commit 1f7ce52

File tree

2 files changed

+113
-50
lines changed

2 files changed

+113
-50
lines changed

Dockerfile

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,41 @@ RUN cargo build --release && strip target/release/term-executor
1010

1111
# ── Runtime stage ──
1212
FROM debian:bookworm-slim
13+
14+
# Pre-install all common runtimes and tools at build time (as root)
1315
RUN apt-get update && apt-get install -y --no-install-recommends \
14-
ca-certificates git curl unzip libssl3 libssl-dev pkg-config \
15-
python3 python3-pip python3-venv \
16-
build-essential nodejs npm \
16+
ca-certificates git curl wget unzip libssl3 libssl-dev pkg-config sudo \
17+
python3 python3-pip python3-venv python3-dev \
18+
build-essential gcc g++ make cmake autoconf automake libtool \
19+
nodejs npm \
1720
golang-go \
18-
default-jdk maven \
21+
default-jdk maven gradle \
22+
ruby ruby-dev \
23+
libffi-dev libxml2-dev libxslt1-dev zlib1g-dev libyaml-dev \
24+
libreadline-dev libncurses-dev libgdbm-dev libdb-dev \
25+
sqlite3 libsqlite3-dev postgresql-client libpq-dev \
26+
imagemagick libmagickwand-dev \
27+
jq \
1928
&& ln -sf /usr/bin/python3 /usr/bin/python \
2029
&& npm install -g corepack yarn pnpm \
2130
&& corepack enable \
2231
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
2332
&& rm -rf /var/lib/apt/lists/*
2433
ENV PATH="/root/.cargo/bin:${PATH}"
34+
35+
# Create non-root 'agent' user for running agent code and install commands.
36+
# Agents can apt-install as this user (has passwordless sudo).
37+
RUN useradd -m -s /bin/bash agent \
38+
&& echo "agent ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/agent \
39+
&& chmod 0440 /etc/sudoers.d/agent \
40+
&& cp -r /root/.cargo /home/agent/.cargo \
41+
&& chown -R agent:agent /home/agent/.cargo \
42+
&& mkdir -p /home/agent/.local/bin \
43+
&& chown -R agent:agent /home/agent
44+
ENV AGENT_USER=agent
45+
2546
COPY --from=builder /build/target/release/term-executor /usr/local/bin/
26-
RUN mkdir -p /tmp/sessions
47+
RUN mkdir -p /tmp/sessions && chmod 777 /tmp/sessions
2748
ENV IMAGE_NAME=platformnetwork/term-executor
2849
ENV IMAGE_DIGEST=""
2950
EXPOSE 8080

src/executor.rs

Lines changed: 87 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ use crate::task::{ExtractedArchive, SweForgeTask};
1515

1616
const MAX_OUTPUT: usize = 1024 * 1024;
1717

18+
fn shell_escape(s: &str) -> String {
19+
format!("'{}'", s.replace('\'', "'\\''"))
20+
}
21+
1822
fn truncate_output(raw: &[u8]) -> String {
1923
if raw.len() <= MAX_OUTPUT {
2024
String::from_utf8_lossy(raw).to_string()
@@ -64,6 +68,7 @@ async fn run_cmd(
6468
))
6569
}
6670

71+
#[allow(dead_code)]
6772
async fn run_shell(
6873
shell_cmd: &str,
6974
cwd: &Path,
@@ -73,22 +78,59 @@ async fn run_shell(
7378
run_cmd(&["sh", "-c", shell_cmd], cwd, timeout, env).await
7479
}
7580

76-
/// Filter out system-level package commands that can't run in restricted containers.
77-
/// Keeps project-level install commands (npm install, pip install, yarn install, etc.)
78-
fn filter_install_command(cmd: &str) -> String {
79-
let system_prefixes = [
80-
"apt-get", "apt ", "dpkg", "yum ", "dnf ", "pacman ", "apk ", "snap ", "flatpak ",
81-
];
81+
/// Get the agent user name from AGENT_USER env var (default: "agent").
82+
fn agent_user() -> String {
83+
std::env::var("AGENT_USER").unwrap_or_else(|_| "agent".to_string())
84+
}
85+
86+
/// Wrap a shell command to run as the agent user via sudo.
87+
fn as_agent_cmd(shell_cmd: &str, cwd: &Path) -> Vec<String> {
88+
let user = agent_user();
89+
vec![
90+
"sudo".to_string(),
91+
"-u".to_string(),
92+
user,
93+
"--preserve-env=PATH,HOME,JAVA_HOME,GOPATH,CARGO_HOME,NODE_PATH".to_string(),
94+
"sh".to_string(),
95+
"-c".to_string(),
96+
format!("cd {} && {}", cwd.display(), shell_cmd),
97+
]
98+
}
8299

83-
// Split on && and filter out system commands
100+
/// Run a shell command as the agent user.
101+
async fn run_shell_as_agent(
102+
shell_cmd: &str,
103+
cwd: &Path,
104+
timeout: Duration,
105+
env: Option<&[(&str, &str)]>,
106+
) -> Result<(String, String, i32)> {
107+
let argv_owned = as_agent_cmd(shell_cmd, cwd);
108+
let argv: Vec<&str> = argv_owned.iter().map(|s| s.as_str()).collect();
109+
run_cmd(&argv, cwd, timeout, env).await
110+
}
111+
112+
/// Prepare install command: add -y flag to apt-get commands, add DEBIAN_FRONTEND=noninteractive.
113+
fn prepare_install_command(cmd: &str) -> String {
84114
let parts: Vec<&str> = cmd.split("&&").collect();
85-
let filtered: Vec<&str> = parts
115+
let prepared: Vec<String> = parts
86116
.iter()
87-
.map(|p| p.trim())
88-
.filter(|p| !system_prefixes.iter().any(|prefix| p.starts_with(prefix)))
117+
.map(|p| {
118+
let trimmed = p.trim();
119+
if trimmed.starts_with("apt-get") || trimmed.starts_with("apt ") {
120+
if trimmed.contains("install") && !trimmed.contains("-y") {
121+
format!(
122+
"DEBIAN_FRONTEND=noninteractive sudo {}",
123+
trimmed.replace("install", "install -y")
124+
)
125+
} else {
126+
format!("DEBIAN_FRONTEND=noninteractive sudo {}", trimmed)
127+
}
128+
} else {
129+
trimmed.to_string()
130+
}
131+
})
89132
.collect();
90-
91-
filtered.join(" && ")
133+
prepared.join(" && ")
92134
}
93135

94136
pub struct Executor {
@@ -347,21 +389,24 @@ async fn run_task_pipeline(
347389
}
348390

349391
result.status = TaskStatus::InstallingDeps;
392+
// Ensure repo_dir is writable by the agent user
393+
let _ = run_cmd(
394+
&["chmod", "-R", "a+rwX", &repo_dir.to_string_lossy()],
395+
work_dir,
396+
Duration::from_secs(30),
397+
None,
398+
)
399+
.await;
350400
if let Some(ref install_cmds) = task.workspace.install {
351401
for cmd in install_cmds {
352-
// Split chained commands and filter out system package commands
353-
// that can't run in a restricted container (apt-get, dpkg, etc.)
354-
let effective_cmd = filter_install_command(cmd);
355-
if effective_cmd.is_empty() {
356-
info!(
357-
"[{}] Skipping system install: {}",
358-
task.id,
359-
&cmd[..cmd.len().min(100)]
360-
);
361-
continue;
362-
}
363-
info!("[{}] Installing: {}", task.id, effective_cmd);
364-
let (_, stderr, exit) = run_shell(
402+
let effective_cmd = prepare_install_command(cmd);
403+
info!(
404+
"[{}] Installing (as {}): {}",
405+
task.id,
406+
agent_user(),
407+
effective_cmd
408+
);
409+
let (_, stderr, exit) = run_shell_as_agent(
365410
&effective_cmd,
366411
&repo_dir,
367412
Duration::from_secs(config.clone_timeout_secs),
@@ -536,20 +581,21 @@ async fn run_agent(
536581
tokio::fs::write(&prompt_path, prompt).await?;
537582

538583
let argv_owned = agent_runner(agent_language, &script_name);
539-
let argv: Vec<&str> = argv_owned.iter().map(|s| s.as_str()).collect();
540-
info!("Running agent: {:?}", argv);
541-
542-
let env_vars = [
543-
("TASK_PROMPT", prompt_path.to_string_lossy().to_string()),
544-
("REPO_DIR", repo_dir.to_string_lossy().to_string()),
545-
];
546-
let env_refs: Vec<(&str, &str)> = env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect();
547-
548-
let (stdout, stderr, exit) = run_cmd(
549-
&argv,
584+
let runner_cmd = argv_owned.join(" ");
585+
info!("Running agent as {}: {}", agent_user(), runner_cmd);
586+
587+
let env_export = format!(
588+
"export TASK_PROMPT={} REPO_DIR={} && {}",
589+
shell_escape(&prompt_path.to_string_lossy()),
590+
shell_escape(&repo_dir.to_string_lossy()),
591+
runner_cmd,
592+
);
593+
594+
let (stdout, stderr, exit) = run_shell_as_agent(
595+
&env_export,
550596
repo_dir,
551597
Duration::from_secs(timeout_secs),
552-
Some(&env_refs),
598+
None,
553599
)
554600
.await?;
555601

@@ -581,14 +627,10 @@ async fn run_tests(
581627
let _ = std::fs::set_permissions(&script_path, perms);
582628
}
583629

584-
debug!("Running test script: {}", name);
585-
let result = run_cmd(
586-
&["bash", &script_path.to_string_lossy()],
587-
repo_dir,
588-
Duration::from_secs(timeout_secs),
589-
None,
590-
)
591-
.await;
630+
debug!("Running test script as {}: {}", agent_user(), name);
631+
let test_cmd = format!("bash {}", shell_escape(&script_path.to_string_lossy()));
632+
let result =
633+
run_shell_as_agent(&test_cmd, repo_dir, Duration::from_secs(timeout_secs), None).await;
592634

593635
match result {
594636
Ok((stdout, stderr, exit)) => {

0 commit comments

Comments
 (0)