Skip to content
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ ${EDITOR:-vi} "$CATALOG/agents/<host>/<identity>/agent.kdl"
```

Replace `<host>`, `<identity>`, `<workspace>`, and `<boot prompt>`. Add every file referenced by
`copy` under `$CATALOG/_templates`.
`copy` under `$CATALOG/_templates`. The maintained declaration does not add workspace trust.
`compile-agent` also omits trust by default. Pass `--harness codex --trust-workspace` to opt in to an
argv-local Codex `projects` trust override. The generator serializes the declared workspace as the
exact decoded key; other harnesses reject the flag. This is a launch convention inside opaque argv,
not agent-spec grammar enforced by st2.

The compact declaration shape is:

Expand Down
6 changes: 5 additions & 1 deletion examples/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ The examples use `<host>`, `<identity>`, and `<workspace>` placeholders. st2 pro
machine-specific install paths. Copy the appropriate file into
`<catalog>/agents/<host>/<identity>/agent.kdl`, replace every placeholder, and add the referenced
catalog-owned templates. `role` is optional metadata; `supervisor` is optional runtime routing.
Uncomment them when the seat has an assigned role or reports to another bus identity.
Uncomment them when the agent has an assigned role or reports to another bus identity. In the Codex
declaration, workspace trust is absent by default. The experimental generator adds an argv-local
Codex project trust override only with `compile-agent --harness codex --trust-workspace`. It preserves
the exact decoded workspace bytes as the key. st2 treats that argv as opaque; the trust flag is not
part of generic catalog validation.

## Lifecycle

Expand Down
1 change: 1 addition & 0 deletions examples/native/agent-codex.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ agent "<identity>" {
// st2 owns the hook declaration and installed scripts for this unattended seat. The rendered bus
// contract requires agent-declared status: busy while executing work, available only when
// yielding/ready, and dnd only for an explicit hold.
// Harness-specific launch policy stays inside this opaque argv; st2 does not parse its flags.
argv "codex" "--dangerously-bypass-approvals-and-sandbox" "--dangerously-bypass-hook-trust" "<boot prompt>"
ding

Expand Down
34 changes: 29 additions & 5 deletions src/compile_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub struct AgentInput {
pub host: String,
pub role: String,
pub harness: String,
pub trust_workspace: bool,
pub model: Option<String>,
pub workspace: String,
pub supervisor: Option<String>,
Expand All @@ -37,11 +38,17 @@ impl AgentInput {
"--permission-mode".to_string(),
"bypassPermissions".to_string(),
],
"codex" => vec![
"codex".to_string(),
"--dangerously-bypass-approvals-and-sandbox".to_string(),
"--dangerously-bypass-hook-trust".to_string(),
],
"codex" => {
let mut argv = vec!["codex".to_string()];
if self.trust_workspace {
argv.extend(["-c".to_string(), codex_project_trust(&self.workspace)]);
}
argv.extend([
"--dangerously-bypass-approvals-and-sandbox".to_string(),
"--dangerously-bypass-hook-trust".to_string(),
]);
argv
}
other => anyhow::bail!(
"compile-agent: harness '{other}' is not supported (expected claude or codex)"
),
Expand All @@ -55,6 +62,20 @@ impl AgentInput {
}
}

/// One generator-owned, argv-local Codex config override. TOML's table serializer owns key
/// escaping so the decoded key is byte-identical to the declared workspace even when it contains
/// quotes or slashes. Generic catalog validation continues to treat the resulting argv as opaque.
fn codex_project_trust(workspace: &str) -> String {
let mut project = toml::Table::new();
project.insert(
"trust_level".to_string(),
toml::Value::String("trusted".to_string()),
);
let mut projects = toml::Table::new();
projects.insert(workspace.to_string(), toml::Value::Table(project));
format!("projects={}", toml::Value::Table(projects))
}

fn kdl_str(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
Expand Down Expand Up @@ -133,6 +154,9 @@ pub fn compile_agent(
input.harness
);
}
if input.trust_workspace && input.harness != "codex" {
anyhow::bail!("compile-agent: --trust-workspace requires --harness codex");
}
let persona = fs::read_to_string(persona_file).map_err(|error| {
anyhow::anyhow!(
"reading persona {} for '{}': {error}",
Expand Down
18 changes: 17 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ enum Command {
persona: PathBuf,
#[arg(long, default_value = "claude")]
harness: String,
/// Add an argv-local Codex project trust override for the exact workspace. This is opt-in
/// and requires `--harness codex`.
#[arg(long)]
trust_workspace: bool,
/// Host (defaults to the local hostname).
#[arg(long)]
host: Option<String>,
Expand Down Expand Up @@ -595,14 +599,24 @@ fn main() -> Result<()> {
dir,
persona,
harness,
trust_workspace,
host,
model,
supervisor,
extra_arg,
} => {
let catalog = catalog_arg(catalog)?;
compile_agent_cmd(
&catalog, &identity, &role, &dir, &persona, &harness, host, model, supervisor,
&catalog,
&identity,
&role,
&dir,
&persona,
&harness,
trust_workspace,
host,
model,
supervisor,
extra_arg,
)
}
Expand Down Expand Up @@ -663,6 +677,7 @@ fn compile_agent_cmd(
dir: &str,
persona: &Path,
harness: &str,
trust_workspace: bool,
host: Option<String>,
model: Option<String>,
supervisor: Option<String>,
Expand All @@ -674,6 +689,7 @@ fn compile_agent_cmd(
host: host.clone(),
role: role.to_string(),
harness: harness.to_string(),
trust_workspace,
model,
workspace: dir.to_string(),
supervisor,
Expand Down
120 changes: 120 additions & 0 deletions tests/compile_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,131 @@ fn compile_agent_generates_codex_then_materializes_composed_agents_md() {
let kdl = fs::read_to_string(catalog.join("agents/h/worker/agent.kdl")).unwrap();
assert!(kdl.contains("argv \"codex\""));
assert!(!kdl.contains("exec codex"));
assert!(!kdl.contains("\"-c\""));
assert!(!kdl.contains("projects="));
assert!(kdl.contains("set status busy"));
assert!(kdl.contains("--dangerously-bypass-hook-trust"));
assert!(kdl.contains("json-upsert \".codex/hooks.json\""));
}

#[test]
fn compile_agent_opt_in_codex_trust_roundtrips_workspace_bytes_through_toml_and_kdl() {
let tmp = tempfile::tempdir().unwrap();
let catalog = tmp.path().join("catalog");
let workspace = tmp
.path()
.join("workspace with 'single \"# double and \\backslash");
let hooks_root = tmp.path().join("hooks");
let persona = tmp.path().join("worker.md");
fs::create_dir_all(&workspace).unwrap();
fs::write(&persona, "# Worker\n").unwrap();

let output = Command::new(env!("CARGO_BIN_EXE_st2"))
.arg("compile-agent")
.arg(&catalog)
.args([
"--role",
"worker",
"--identity",
"worker",
"--host",
"h",
"--harness",
"codex",
"--trust-workspace",
])
.arg("--dir")
.arg(&workspace)
.arg("--persona")
.arg(&persona)
.env("ST_HOOKS", &hooks_root)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);

let found = discover(&catalog);
assert!(found.errors.is_empty(), "{:?}", found.errors);
let spec = found
.specs
.iter()
.find(|spec| spec.identity == "worker")
.unwrap();
let workspace_text = workspace.to_str().unwrap();
assert_eq!(spec.workspace.as_deref(), Some(workspace_text));
let argv = spec
.tasks
.iter()
.find(|task| task.name == "agent")
.and_then(|task| task.argv.as_deref())
.unwrap();
let report = st2::validate::validate(&catalog);
assert_eq!(
report.errors(),
0,
"generated output must validate: {:?}",
report.issues
);

assert_eq!(argv.first().map(String::as_str), Some("codex"));
assert_eq!(argv.get(1).map(String::as_str), Some("-c"));
let config = argv.get(2).unwrap();
let config = config.parse::<toml::Table>().unwrap();
let projects = config["projects"].as_table().unwrap();
let (key, project) = projects.iter().next().unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(key.as_bytes(), workspace_text.as_bytes());
assert_eq!(
project["trust_level"].as_str(),
Some("trusted"),
"argv: {argv:?}"
);
}

#[test]
fn compile_agent_rejects_workspace_trust_for_non_codex_before_writing() {
let tmp = tempfile::tempdir().unwrap();
let catalog = tmp.path().join("catalog");
let workspace = tmp.path().join("workspace");
let persona = tmp.path().join("worker.md");
fs::create_dir_all(&workspace).unwrap();
fs::write(&persona, "# Worker\n").unwrap();

let output = Command::new(env!("CARGO_BIN_EXE_st2"))
.arg("compile-agent")
.arg(&catalog)
.args([
"--identity",
"worker",
"--host",
"h",
"--harness",
"claude",
"--trust-workspace",
])
.arg("--dir")
.arg(&workspace)
.arg("--persona")
.arg(&persona)
.output()
.unwrap();

assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("--trust-workspace requires --harness codex"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!catalog.exists(),
"a rejected trust/harness combination wrote catalog output"
);
}

#[test]
fn removed_generator_aliases_are_unknown_commands() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
Loading