Close four `oa` divergences from the TypeScript CLI (#88)

0f40e431ea89 · AtlantisPleb · · parent b3cac99daedf

Close four `oa` divergences from the TypeScript CLI (#88)

Each one made the tool say something untrue about the machine it runs on,
or offer something it could not do.

The git credential helper. `auth setup-git` wrote `current_exe()`
canonicalized into git config, so a helper installed from `target/debug/oa`
stopped resolving the moment that build moved; `auth status` compared the
config against its *own* `current_exe()`, so it reported a configured
helper as absent whenever the two binaries differed — including the
`!openagents …` the TypeScript CLI installs, which is a live helper for the
same origin. It now writes the stable name `!oa --api-url <origin> auth
git-credential`, and reads a helper as configured when it answers this
origin and the program is one of ours, whatever path or build wrote it.

`repo list` printed a trailing `\t(branch: main)`. That output is piped;
the slug alone is what the TypeScript CLI emits.

`repo create` gained `--source` and `--remote`, mirroring `attachRemote`
and `nextPushArguments`: it attaches the new repository to an existing
worktree and prints the next `git push`. Both refusals — a `--source` that
is not a worktree, a `--remote` git will not take — are checked before the
create, because a refusal that has already made a repository on the server
is not a refusal. `--remote` without `--source` is refused rather than
ignored.

`oa coder` gained `--child-model`, `--child-command`, `--child-config`,
`--child-ask`, and `--concurrency`. `--child-config` is the only route a
provider credential has to a delegated child, since this CLI stores none,
so a lane needing its own credential could not be configured from `oa
coder` at all. They reach the child from both entry points: `--delegate`
through `DelegationRequest`, and `/delegate` and the `delegate` tool
through the session's `DelegationGate`. A flag the chosen lane cannot
honour is refused by name rather than dropped. The tool's children now also
work in the session's directory rather than wherever the process happens to
be.

Verified against production side by side with `openagents` v0.4.0: `auth
status` and `repo list` byte-identical, `repo create --source` identical in
human and JSON form, and a real push over the newly written helper.

Tests round-trip rather than assert strings in isolation: `setup-git` then
`auth status` through the real binary, and the `--child-*` values read back
out of a real child process's argv and environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/delegate.rs
  • modified crates/openagents-cli/src/interactive.rs
  • modified crates/openagents-cli/src/repo.rs
  • modified crates/openagents-cli/src/tools.rs
  • modified crates/openagents-cli/tests/auth_repo_test.rs
  • modified crates/openagents-cli/tests/delegate_test.rs

Diff

7 files changed, +918 -66

crates/openagents-cli/src/cli.rs modified +135 -6

@@ -528,6 +528,16 @@ pub enum RepoAction {

528 528
            help = "Seconds to wait for durable provisioning (0 does not wait)"
529 529
        )]
530 530
        wait_timeout: u64,
531
        #[arg(
532
            long,
533
            help = "Attach the new repository to an existing git worktree"
534
        )]
535
        source: Option<String>,
536
        #[arg(
537
            long,
538
            help = "Name the git remote attached with --source (defaults to origin)"
539
        )]
540
        remote: Option<String>,
531 541
    },
532 542
    /// Import a GitHub repository once
533 543
    Import {

@@ -587,7 +597,15 @@ pub struct CoderArgs {

587 597
    #[arg(long, default_value_t = 1, help = "How many child agents run the prompt")]
588 598
    pub count: usize,
589 599
590
    #[arg(long, help = "How many children run at once. Defaults to all of them")]
600
    /// `--concurrency` is the name the TypeScript CLI gives this, and the name
601
    /// `oa delegate` already gives it. An alias rather than a second field:
602
    /// two flags that both set one value can be written together and then
603
    /// disagree, and nothing would say which one won.
604
    #[arg(
605
        long,
606
        visible_alias = "concurrency",
607
        help = "How many children run at once. Defaults to all of them"
608
    )]
591 609
    pub max_parallel: Option<usize>,
592 610
593 611
    #[arg(

@@ -599,6 +617,36 @@ pub struct CoderArgs {

599 617
    #[arg(long, help = "Leave the children's worktrees on disk so their work can be read")]
600 618
    pub keep_workspaces: bool,
601 619
620
    // The four `--child-*` flags. `oa delegate` declared them and `oa coder
621
    // --delegate` did not, so a fan-out started from the coder command could
622
    // not be given a harness, a model, or a config file — and `--child-config`
623
    // is the only route a provider credential has to a child, because this CLI
624
    // deliberately never stores one. A lane that needed its own credential was
625
    // therefore unreachable from `oa coder` at all.
626
    #[arg(
627
        long,
628
        help = "Run children on this model instead of the lane's own, as `provider/model`. Defaults to OPENAGENTS_DELEGATE_MODEL"
629
    )]
630
    pub child_model: Option<String>,
631
632
    #[arg(
633
        long,
634
        help = "The harness that runs a child. Defaults to OPENAGENTS_DELEGATE_COMMAND, or the lane's own binary"
635
    )]
636
    pub child_command: Option<String>,
637
638
    #[arg(
639
        long,
640
        help = "A harness config file for children, passed as OPENCODE_CONFIG. This is how a provider credential reaches a child without being stored by the CLI"
641
    )]
642
    pub child_config: Option<String>,
643
644
    #[arg(
645
        long,
646
        help = "Make children ask before using a tool. A delegated child has nobody to ask, so this stops it at its first edit; it exists for a dry run over a directory you do not want touched"
647
    )]
648
    pub child_ask: bool,
649
602 650
    #[arg(long, help = "Target harness lane (e.g. ox-alpha, gemini, devin, claude, codex)")]
603 651
    pub lane: Option<String>,
604 652

@@ -773,6 +821,19 @@ impl CoderArgs {

773 821
        }
774 822
    }
775 823
824
    /// The four `--child-*` flags, resolved with their environment fallbacks.
825
    ///
826
    /// One place, so a session that delegates through `/delegate` or the
827
    /// `delegate` tool starts its children on exactly what `--delegate` would.
828
    pub fn child_options(&self) -> crate::delegate::ChildOptions {
829
        crate::delegate::ChildOptions::resolve(
830
            self.child_model.clone(),
831
            self.child_command.clone(),
832
            self.child_config.clone(),
833
            self.child_ask,
834
        )
835
    }
836
776 837
    /// Whether any of `--lane`, `--model` or `--local` was written.
777 838
    ///
778 839
    /// A resumed thread already holds the model its grant pins, so naming one

@@ -1958,11 +2019,11 @@ async fn run_repo(action: RepoAction, endpoint: &Endpoint, store: &CredentialSto

1958 2019
            } else if listed.repositories.is_empty() {
1959 2020
                println!("No repositories found.");
1960 2021
            } else {
2022
                // The slug alone, as the TypeScript CLI prints it. This output
2023
                // is piped, and a trailing `\t(branch: main)` makes every
2024
                // consumer of it cut a field off first.
1961 2025
                for repository in &listed.repositories {
1962
                    println!(
1963
                        "{}\t(branch: {})",
1964
                        repository.full_name, repository.default_branch
1965
                    );
2026
                    println!("{}", repository.full_name);
1966 2027
                }
1967 2028
                if let Some(cursor) = listed.next_cursor {
1968 2029
                    println!("Next cursor: {cursor}");

@@ -1987,7 +2048,31 @@ async fn run_repo(action: RepoAction, endpoint: &Endpoint, store: &CredentialSto

1987 2048
            private,
1988 2049
            default_branch,
1989 2050
            wait_timeout,
2051
            source,
2052
            remote,
1990 2053
        } => {
2054
            // Both checked before the repository is created, not after: a
2055
            // refusal that has already made a repository on the server is not
2056
            // a refusal.
2057
            //
2058
            // `--remote` names the remote `--source` attaches; on its own it
2059
            // has nothing to name. The TypeScript CLI ignores it silently,
2060
            // which leaves a reader believing they configured something.
2061
            if remote.is_some() && source.is_none() {
2062
                fail("--remote names the remote --source attaches. Give --source too");
2063
            }
2064
            if let Some(remote) = remote.as_deref() {
2065
                or_fail(crate::repo::validate_remote_name(remote));
2066
            }
2067
            // A `--source` that is not a worktree cannot be attached, and
2068
            // finding that out after the create leaves a repository on the
2069
            // server that the reader did not get told how to push to.
2070
            if let Some(directory) = source.as_deref() {
2071
                or_fail(crate::repo::require_worktree(std::path::Path::new(
2072
                    directory,
2073
                )));
2074
            }
2075
1991 2076
            let is_private = visibility(public, private).unwrap_or(false);
1992 2077
            let (owner, repository_name) = if name.contains('/') {
1993 2078
                let (owner, repository_name) = or_fail(crate::repo::parse_repository_target(&name));

@@ -2007,13 +2092,56 @@ async fn run_repo(action: RepoAction, endpoint: &Endpoint, store: &CredentialSto

2007 2092
                    )
2008 2093
                    .await,
2009 2094
            );
2095
            // A repository still provisioning has no clone URL to attach yet,
2096
            // so `--source` is reported as not done rather than done wrong.
2097
            let attached = match source.as_deref() {
2098
                Some(directory) if created.lifecycle_state == "ready" => {
2099
                    let (_, clone_url) = or_fail(
2100
                        client
2101
                            .clone_info(&created.owner.login, &created.name)
2102
                            .await,
2103
                    );
2104
                    let path = std::path::PathBuf::from(directory);
2105
                    let remote = remote.as_deref().unwrap_or("origin");
2106
                    Some((
2107
                        or_fail(crate::repo::attach_remote(
2108
                            &endpoint.origin,
2109
                            &clone_url,
2110
                            &path,
2111
                            remote,
2112
                        )),
2113
                        path,
2114
                    ))
2115
                }
2116
                _ => None,
2117
            };
2118
2010 2119
            if json {
2011
                print_json(&serde_json::to_value(&created).unwrap_or(serde_json::Value::Null));
2120
                let mut value =
2121
                    serde_json::to_value(&created).unwrap_or(serde_json::Value::Null);
2122
                if let Some((attached, path)) = &attached {
2123
                    value = serde_json::json!({
2124
                        "repository": value,
2125
                        "remote": attached.remote,
2126
                        "next_push": attached.next_push_argv(path),
2127
                    });
2128
                }
2129
                print_json(&value);
2012 2130
            } else {
2013 2131
                println!("Repository created.");
2014 2132
                for line in created.human_lines() {
2015 2133
                    println!("{line}");
2016 2134
                }
2135
                match (&attached, source.as_deref()) {
2136
                    (Some((attached, path)), Some(directory)) => {
2137
                        println!("Configured remote {} in {directory}.", attached.remote);
2138
                        println!("Next: {}", attached.next_push_command(path));
2139
                    }
2140
                    (None, Some(_)) => println!(
2141
                        "The repository is still provisioning, so the CLI did not configure a remote."
2142
                    ),
2143
                    _ => {}
2144
                }
2017 2145
            }
2018 2146
        }
2019 2147
        RepoAction::Import {

@@ -4439,6 +4567,7 @@ async fn run_headless_coder(

4439 4567
            lane: lane_name.clone(),
4440 4568
            user_token: token.clone(),
4441 4569
            max_count: crate::delegate::MAX_DELEGATE_COUNT,
4570
            child: coder.child_options(),
4442 4571
        },
4443 4572
    );
4444 4573
    let lane = crate::runtime::Lane::from_str(&lane_name);
crates/openagents-cli/src/delegate.rs modified +37 -14

@@ -1166,9 +1166,10 @@ impl Printer {

1166 1166
///
1167 1167
/// `oa delegate` and `oa coder --delegate` run the same engine, so they resolve
1168 1168
/// to the same request rather than each carrying its own copy of the argument
1169
/// handling. The coder flag reaches only the fields the coder command declares;
1170
/// the `--child-*` and `--dir` flags exist on `oa delegate` alone, which is why
1171
/// they are `None` on that side rather than silently defaulted.
1169
/// handling. Both commands now declare the `--child-*` flags, so a fan-out
1170
/// started from either can be given a harness, a model, and a config file;
1171
/// `--dir` and `--description` remain `oa delegate`'s alone, which is why they
1172
/// are `None` on the coder side rather than silently defaulted.
1172 1173
#[derive(Debug, Clone)]
1173 1174
pub struct DelegationRequest {
1174 1175
    pub prompt: Option<String>,

@@ -1196,10 +1197,10 @@ impl DelegationRequest {

1196 1197
            keep_workspaces: args.keep_workspaces,
1197 1198
            directory: None,
1198 1199
            description: None,
1199
            child_model: None,
1200
            child_command: None,
1201
            child_config: None,
1202
            child_ask: false,
1200
            child_model: args.child_model,
1201
            child_command: args.child_command,
1202
            child_config: args.child_config,
1203
            child_ask: args.child_ask,
1203 1204
        }
1204 1205
    }
1205 1206

@@ -1471,6 +1472,8 @@ pub fn fanout_for_tool(

1471 1472
    count: usize,
1472 1473
    lane: &str,
1473 1474
    user_token: Option<String>,
1475
    child: ChildOptions,
1476
    directory: Option<PathBuf>,
1474 1477
) -> std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send>> {
1475 1478
    let prompt = prompt.to_string();
1476 1479
    let lane = lane.to_string();

@@ -1478,7 +1481,18 @@ pub fn fanout_for_tool(

1478 1481
    let prompt = prompt.as_str();
1479 1482
    let lane = lane.as_str();
1480 1483
    let count = count.clamp(1, MAX_DELEGATE_COUNT);
1481
    let supervisor = DelegationSupervisor::new(count, lane, user_token);
1484
    // A flag the lane cannot honour is reported to the caller rather than
1485
    // dropped: the session asked for a model or a dry run and would otherwise
1486
    // get a fan-out without either, with nothing said.
1487
    if let Err(why) = child.check(&ChildLane::parse(lane)) {
1488
        return format!("No children were started: {why}");
1489
    }
1490
    // Children work where the session works. The version this replaces took
1491
    // whatever the process's own directory happened to be, which is the same
1492
    // thing only until something changes it.
1493
    let supervisor = DelegationSupervisor::new(count, lane, user_token)
1494
        .with_child_options(child)
1495
        .in_directory(directory);
1482 1496
    let results = supervisor.dispatch(prompt).await;
1483 1497
1484 1498
    let succeeded = results.iter().filter(|result| result.success).count();

@@ -1670,9 +1684,9 @@ mod child_option_tests {

1670 1684
1671 1685
    /// The two commands that run a fan-out resolve to the same request.
1672 1686
    ///
1673
    /// `oa coder --delegate` declares none of the `--child-*` flags, so they
1674
    /// arrive as `None` rather than as a default that would be indistinguishable
1675
    /// from the reader having chosen it.
1687
    /// Including the `--child-*` flags: `oa coder --delegate --child-config f`
1688
    /// used to drop the config on the floor, which is the only route a provider
1689
    /// credential has to a child.
1676 1690
    #[test]
1677 1691
    fn both_entry_points_resolve_to_one_request() {
1678 1692
        let request = DelegationRequest::from_delegate(DelegateArgs {

@@ -1698,9 +1712,13 @@ mod child_option_tests {

1698 1712
            prompt: Some("go".to_string()),
1699 1713
            delegate: true,
1700 1714
            count: 3,
1701
            max_parallel: None,
1715
            max_parallel: Some(2),
1702 1716
            isolation: None,
1703 1717
            keep_workspaces: false,
1718
            child_model: Some("anthropic/claude".to_string()),
1719
            child_command: Some("opencode".to_string()),
1720
            child_config: Some("/tmp/harness.json".to_string()),
1721
            child_ask: true,
1704 1722
            lane: None,
1705 1723
            model: None,
1706 1724
            local: false,

@@ -1716,8 +1734,13 @@ mod child_option_tests {

1716 1734
            dev_port: 4000,
1717 1735
        });
1718 1736
        assert_eq!(request.count, 3);
1719
        assert!(request.child_model.is_none());
1720
        assert!(!request.child_ask);
1737
        assert_eq!(request.max_parallel, Some(2));
1738
        assert_eq!(request.child_model.as_deref(), Some("anthropic/claude"));
1739
        assert_eq!(request.child_command.as_deref(), Some("opencode"));
1740
        assert_eq!(request.child_config.as_deref(), Some("/tmp/harness.json"));
1741
        assert!(request.child_ask);
1742
        // `--dir` and `--description` are still `oa delegate`'s alone.
1721 1743
        assert!(request.directory.is_none());
1744
        assert!(request.description.is_none());
1722 1745
    }
1723 1746
}
crates/openagents-cli/src/interactive.rs modified +10 -5

@@ -1099,13 +1099,18 @@ fn install_panic_hook() {

1099 1099
/// they each built the registry separately before, both passed `None`, and the
1100 1100
/// result was that `delegate` worked headless and was missing from the only
1101 1101
/// session anyone actually types into.
1102
fn session_tools(lane_name: &str, token: &Option<String>) -> HarnessToolRegistry {
1102
fn session_tools(
1103
    lane_name: &str,
1104
    token: &Option<String>,
1105
    child: crate::delegate::ChildOptions,
1106
) -> HarnessToolRegistry {
1103 1107
    HarnessToolRegistry::with_delegation(
1104 1108
        None,
1105 1109
        DelegationGate {
1106 1110
            lane: lane_name.to_string(),
1107 1111
            user_token: token.clone(),
1108 1112
            max_count: crate::delegate::MAX_DELEGATE_COUNT,
1113
            child,
1109 1114
        },
1110 1115
    )
1111 1116
}

@@ -1126,7 +1131,7 @@ pub async fn run_tui(

1126 1131
        return run_without_a_terminal(args, api_base, token, repository, lane, resumed).await;
1127 1132
    }
1128 1133
1129
    let tools = session_tools(&lane_name, &token);
1134
    let tools = session_tools(&lane_name, &token, args.child_options());
1130 1135
    let mut session = CoderRuntimeSession::new(lane.clone(), Some(api_base), token, tools);
1131 1136
    session.reasoning = args.reasoning.clone();
1132 1137
    session.repository = repository;

@@ -1236,7 +1241,7 @@ async fn run_without_a_terminal(

1236 1241
        return Ok(());
1237 1242
    };
1238 1243
1239
    let tools = session_tools(&lane_name, &token);
1244
    let tools = session_tools(&lane_name, &token, args.child_options());
1240 1245
    let mut session = CoderRuntimeSession::new(lane, Some(api_base), token, tools);
1241 1246
    session.reasoning = args.reasoning.clone();
1242 1247
    session.repository = repository;

@@ -1301,7 +1306,7 @@ mod tests {

1301 1306
    /// exactly like a model choosing not to call it.
1302 1307
    #[test]
1303 1308
    fn an_interactive_session_can_delegate() {
1304
        let tools = session_tools("ox-alpha", &Some("token".to_string()));
1309
        let tools = session_tools("ox-alpha", &Some("token".to_string()), Default::default());
1305 1310
        let names: Vec<String> = tools.list_tools().into_iter().map(|t| t.name).collect();
1306 1311
        assert!(
1307 1312
            names.iter().any(|n| n == "delegate"),

@@ -1315,7 +1320,7 @@ mod tests {

1315 1320
    /// lane while the session runs on another, which is worse than no gate.
1316 1321
    #[test]
1317 1322
    fn the_gate_carries_this_sessions_lane_and_credential() {
1318
        let tools = session_tools("claude", &Some("secret-token".to_string()));
1323
        let tools = session_tools("claude", &Some("secret-token".to_string()), Default::default());
1319 1324
        let gate = tools
1320 1325
            .delegation
1321 1326
            .as_ref()
crates/openagents-cli/src/repo.rs modified +200 -35

@@ -599,43 +599,71 @@ fn idempotency_key() -> String {

599 599
// git
600 600
// ---------------------------------------------------------------------------
601 601
602
/// Quote a value for the shell git runs a `!`-prefixed helper through.
603
fn shell_argument(value: &str) -> String {
604
    let plain = !value.is_empty()
605
        && value
606
            .bytes()
607
            .all(|byte| byte.is_ascii_alphanumeric() || b"_./:@=-".contains(&byte));
608
    if plain {
609
        value.to_string()
610
    } else {
611
        format!("'{}'", value.replace('\'', "'\"'\"'"))
612
    }
613
}
614
615
/// The path of the running binary, which is the program the credential helper
616
/// names.
602
/// The program name the credential helper is installed under.
617 603
///
618
/// A bare `oa` would be resolved by the shell against `PATH`, and on a machine
619
/// that also has an older `oa` installed — the common case while this port
620
/// lands — git would run that one instead, which does not understand
621
/// `--api-url` and answers nothing. Naming the path makes the helper this CLI.
622
pub fn cli_program_path() -> String {
623
    std::env::current_exe()
624
        .ok()
625
        .and_then(|path| path.canonicalize().ok())
626
        .map(|path| path.display().to_string())
627
        .unwrap_or_else(|| "oa".to_string())
628
}
604
/// A stable name, resolved on `PATH` by the shell git runs a `!`-prefixed
605
/// helper through — deliberately not the path of the running binary. What goes
606
/// into git config outlives the process that wrote it: a helper installed from
607
/// `target/debug/oa` has to keep working after that build is rebuilt
608
/// elsewhere, moved, or replaced by an installed release, and an absolute path
609
/// stops resolving the moment any of that happens. The TypeScript CLI installs
610
/// itself under `!openagents` for the same reason.
611
pub const CLI_PROGRAM_NAME: &str = "oa";
629 612
630 613
/// The git credential helper line this CLI installs.
631 614
///
632 615
/// The `!` makes git run it as a shell command with the operation appended, so
633
/// `credential.<origin>.helper` resolves to `<oa> --api-url <origin> auth
616
/// `credential.<origin>.helper` resolves to `oa --api-url <origin> auth
634 617
/// git-credential get`.
635 618
pub fn credential_helper_command(origin: &str) -> String {
636
    format!(
637
        "!{} --api-url {origin} auth git-credential",
638
        shell_argument(&cli_program_path())
619
    format!("!{CLI_PROGRAM_NAME} --api-url {origin} auth git-credential")
620
}
621
622
/// Undo the shell quoting an older build wrote a helper path with.
623
///
624
/// Only the single-quoted form is unwound, because it is the only one this CLI
625
/// ever produced.
626
fn unquote(value: &str) -> String {
627
    match value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
628
        Some(inner) if value.len() >= 2 => inner.replace("'\"'\"'", "'"),
629
        _ => value.to_string(),
630
    }
631
}
632
633
/// Whether one `credential.<origin>.helper` line is an OpenAgents helper for
634
/// this origin.
635
///
636
/// A helper is recognised by what it *does* — it answers `--api-url <origin>
637
/// auth git-credential` — and by the program being one of ours, rather than by
638
/// string equality against the line this build would write today. Three forms
639
/// are the same working helper and all three must read as configured:
640
///
641
/// - `!oa …`, which this CLI now installs.
642
/// - `!openagents …`, which the TypeScript CLI installs. It is a live helper
643
///   for the same origin; calling it absent is a false report about the
644
///   machine.
645
/// - `!/some/path/to/oa …`, which older builds of this CLI installed. Still
646
///   configured, whether or not that path is the binary asking.
647
///
648
/// The version this replaces compared against its own canonicalized
649
/// `current_exe()`, so every one of these read as "not configured" — and two
650
/// `oa` builds in different directories disagreed about the same config line.
651
fn helper_line_matches(line: &str, origin: &str) -> bool {
652
    let Some(rest) = line.trim().strip_prefix('!') else {
653
        return false;
654
    };
655
    let Some(program) = rest.strip_suffix(&format!(" --api-url {origin} auth git-credential"))
656
    else {
657
        return false;
658
    };
659
    let program = unquote(program.trim());
660
    let name = Path::new(&program)
661
        .file_name()
662
        .map(|name| name.to_string_lossy().into_owned())
663
        .unwrap_or_default();
664
    matches!(
665
        name.strip_suffix(".exe").unwrap_or(&name),
666
        CLI_PROGRAM_NAME | "openagents"
639 667
    )
640 668
}
641 669

@@ -694,16 +722,148 @@ pub fn configure_credential_helper(

694 722
695 723
/// Whether the helper is configured locally, globally, or not at all.
696 724
pub fn credential_helper_state(origin: &str, directory: Option<&Path>) -> (bool, bool) {
697
    let expected = credential_helper_command(origin);
698 725
    let key = credential_helper_key(origin);
699 726
    let configured = |scope: &str| {
700 727
        run_git_sync(&["config", scope, "--get-all", &key], directory)
701
            .map(|(code, out)| code == 0 && out.lines().any(|line| line == expected))
728
            .map(|(code, out)| {
729
                code == 0 && out.lines().any(|line| helper_line_matches(line, origin))
730
            })
702 731
            .unwrap_or(false)
703 732
    };
704 733
    (configured("--local"), configured("--global"))
705 734
}
706 735
736
/// What `oa repo create --source` did to the checkout it was pointed at.
737
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
738
pub struct AttachedRemote {
739
    /// The remote the new repository was attached as.
740
    pub remote: String,
741
    /// The `git` arguments that push this checkout to it, so the reader is told
742
    /// the next command rather than left to work it out.
743
    pub next_push_arguments: Vec<String>,
744
}
745
746
impl AttachedRemote {
747
    /// The full argv of the next push, `git -C <directory> push -u <remote> HEAD`.
748
    pub fn next_push_argv(&self, directory: &Path) -> Vec<String> {
749
        let mut argv = vec![
750
            "git".to_string(),
751
            "-C".to_string(),
752
            directory.to_string_lossy().into_owned(),
753
        ];
754
        argv.extend(self.next_push_arguments.iter().cloned());
755
        argv
756
    }
757
758
    /// That argv as one line a reader can paste back into a shell.
759
    pub fn next_push_command(&self, directory: &Path) -> String {
760
        self.next_push_argv(directory)
761
            .iter()
762
            .map(|argument| shell_argument(argument))
763
            .collect::<Vec<_>>()
764
            .join(" ")
765
    }
766
}
767
768
/// Quote one argument so a printed command line can be pasted back into a
769
/// shell. Display only — nothing here is handed to a shell by this process.
770
fn shell_argument(value: &str) -> String {
771
    let plain = !value.is_empty()
772
        && value
773
            .bytes()
774
            .all(|byte| byte.is_ascii_alphanumeric() || b"_./:@=-".contains(&byte));
775
    if plain {
776
        value.to_string()
777
    } else {
778
        format!("'{}'", value.replace('\'', "'\"'\"'"))
779
    }
780
}
781
782
/// A name git will accept as a remote, on the same terms the TypeScript CLI
783
/// admits one: `remoteNamePattern` in `git-runner.ts`.
784
pub fn validate_remote_name(remote: &str) -> Result<String, AuthError> {
785
    let admitted = |remote: &str| {
786
        let mut bytes = remote.bytes();
787
        let Some(first) = bytes.next() else {
788
            return false;
789
        };
790
        first.is_ascii_alphanumeric()
791
            && remote.len() <= 64
792
            && bytes.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
793
            && !remote.contains("..")
794
            && !remote.ends_with('.')
795
            && !remote.ends_with(".lock")
796
    };
797
    if !admitted(remote) {
798
        return Err(AuthError::new(format!("invalid git remote name: {remote}")));
799
    }
800
    Ok(remote.to_string())
801
}
802
803
/// Refuse a directory that is not a git worktree, saying which one.
804
///
805
/// Called once before the repository is created — so a mistyped `--source`
806
/// costs nothing — and again inside [`attach_remote`], which is the call that
807
/// actually depends on it.
808
pub fn require_worktree(directory: &Path) -> Result<(), AuthError> {
809
    let (code, inside) = run_git_sync(&["rev-parse", "--is-inside-work-tree"], Some(directory))?;
810
    if code != 0 || inside.trim() != "true" {
811
        return Err(AuthError::new(format!(
812
            "{} is not a git worktree",
813
            directory.display()
814
        )));
815
    }
816
    Ok(())
817
}
818
819
/// Point an existing checkout at a repository that was just created.
820
///
821
/// Mirrors `GitRunner.attachRemote`. Three refusals, each of them a thing this
822
/// command must not do silently: a URL that is not a repository on the origin
823
/// in use, because the credential helper would then hand this origin's token to
824
/// whatever host the URL named; a directory that is not a worktree, because
825
/// there is nothing to attach and the repository already exists remotely; and a
826
/// remote of that name already pointing somewhere else, because overwriting it
827
/// would detach the checkout from whatever it was pushing to. A remote already
828
/// pointing at this URL is not an error — it is the state being asked for.
829
pub fn attach_remote(
830
    origin: &str,
831
    url: &str,
832
    directory: &Path,
833
    remote: &str,
834
) -> Result<AttachedRemote, AuthError> {
835
    let remote = validate_remote_name(remote)?;
836
    repository_from_remote_url(origin, url)?;
837
    require_worktree(directory)?;
838
839
    let (existing, existing_url) =
840
        run_git_sync(&["remote", "get-url", "--", &remote], Some(directory))?;
841
    if existing == 0 && existing_url.trim() != url {
842
        return Err(AuthError::new(format!(
843
            "remote {remote} already points to {}. The CLI did not overwrite it",
844
            existing_url.trim()
845
        )));
846
    }
847
    if existing != 0 {
848
        let (added, _) = run_git_sync(&["remote", "add", &remote, url], Some(directory))?;
849
        if added != 0 {
850
            return Err(AuthError::new(format!(
851
                "git remote add exited with status {added}"
852
            )));
853
        }
854
    }
855
856
    Ok(AttachedRemote {
857
        next_push_arguments: vec![
858
            "push".to_string(),
859
            "-u".to_string(),
860
            remote.clone(),
861
            "HEAD".to_string(),
862
        ],
863
        remote,
864
    })
865
}
866
707 867
/// `git clone` with this CLI wired in as the only credential helper for the
708 868
/// origin, so a private repository clones without any other credential present.
709 869
pub fn git_clone_argv(url: &str, directory: Option<&str>) -> Vec<String> {

@@ -1017,9 +1177,14 @@ mod tests {

1017 1177
                credential_helper_command("https://openagents.com")
1018 1178
            )
1019 1179
        );
1020
        // The helper names this binary, not a bare `oa` the shell would resolve
1021
        // against PATH — where an older install would answer instead.
1022
        assert!(argv[3].contains(&cli_program_path()), "{}", argv[3]);
1180
        // The helper names `oa` by its stable name, not the path of whichever
1181
        // build wrote it: a clone command that embedded `target/debug/oa`
1182
        // stops working the moment that build moves.
1183
        assert!(
1184
            argv[3].ends_with("=!oa --api-url https://openagents.com auth git-credential"),
1185
            "{}",
1186
            argv[3]
1187
        );
1023 1188
        assert_eq!(argv[argv.len() - 1], "dest");
1024 1189
    }
1025 1190
}
crates/openagents-cli/src/tools.rs modified +11

@@ -139,6 +139,14 @@ pub struct DelegationGate {

139 139
    pub user_token: Option<String>,
140 140
    /// The most children one call may start.
141 141
    pub max_count: usize,
142
    /// The `--child-*` flags the session was started with.
143
    ///
144
    /// Carried here so `/delegate` and the `delegate` tool start children the
145
    /// same way `--delegate` does. Without it `oa coder --child-config f`
146
    /// parsed, said nothing, and started a child that never saw the file —
147
    /// which is the only route a provider credential has to one, since this
148
    /// CLI stores none.
149
    pub child: crate::delegate::ChildOptions,
142 150
}
143 151
144 152
pub struct HarnessToolRegistry {

@@ -509,6 +517,8 @@ impl HarnessToolRegistry {

509 517
                    count,
510 518
                    &gate.lane,
511 519
                    gate.user_token.clone(),
520
                    gate.child.clone(),
521
                    Some(self.cwd.clone()),
512 522
                )
513 523
                .await;
514 524

@@ -1297,6 +1307,7 @@ mod tests {

1297 1307
                lane: "test".to_string(),
1298 1308
                user_token: None,
1299 1309
                max_count: 2,
1310
                child: Default::default(),
1300 1311
            },
1301 1312
        );
1302 1313
        let names: Vec<String> = registry.list_tools().into_iter().map(|t| t.name).collect();
crates/openagents-cli/tests/auth_repo_test.rs modified +299 -6

@@ -10,9 +10,10 @@ use openagents_cli::auth::{

10 10
    PendingDeviceAuthorization, PendingStore, Secret, TokenSource,
11 11
};
12 12
use openagents_cli::repo::{
13
    admitted_credential_request, cli_program_path, configure_credential_helper,
13
    admitted_credential_request, attach_remote, configure_credential_helper,
14 14
    credential_helper_command, credential_helper_state, git_clone_argv, infer_repository,
15
    parse_repository_target, repository_from_remote_url, run_git_credential_helper, RepoClient,
15
    parse_repository_target, repository_from_remote_url, require_worktree,
16
    run_git_credential_helper, validate_remote_name, RepoClient,
16 17
};
17 18
use std::path::Path;
18 19
use std::process::Command;

@@ -338,10 +339,25 @@ fn setup_git_writes_the_credential_helper_into_git_config() {

338 339
    );
339 340
    assert!(values[1].starts_with('!'), "{after}");
340 341
    assert!(values[1].ends_with(" --api-url https://openagents.com auth git-credential"));
341
    // The helper names this binary. A bare `oa` would be resolved against PATH,
342
    // where an older install answers nothing and the clone falls back to a
343
    // password prompt.
344
    assert!(values[1].contains(&cli_program_path()), "{after}");
342
    // The exact value written, asserted as a whole: a stable program name, not
343
    // the path of the build that ran setup-git. The version this replaces wrote
344
    // `current_exe()` canonicalized, so a helper installed from a debug build
345
    // stopped resolving as soon as that build moved.
346
    assert_eq!(
347
        values[1], "!oa --api-url https://openagents.com auth git-credential",
348
        "{after}"
349
    );
350
    assert!(
351
        !values[1].contains(
352
            &std::env::current_exe()
353
                .unwrap()
354
                .parent()
355
                .unwrap()
356
                .display()
357
                .to_string()
358
        ),
359
        "the helper embedded the running build's directory: {after}"
360
    );
345 361
    assert!(credential_helper_state(ORIGIN, Some(directory.path())).0);
346 362
347 363
    // Running it twice must not stack a second copy, which would make git ask

@@ -359,6 +375,135 @@ fn setup_git_writes_the_credential_helper_into_git_config() {

359 375
    );
360 376
}
361 377
378
/// A local origin no keychain on this machine holds a token for, so `oa auth
379
/// status` answers from the git config alone and reaches no network.
380
const OFFLINE_ORIGIN: &str = "http://127.0.0.1:59999";
381
382
/// Run the real `oa` binary in `directory`, with a `HOME` of its own so
383
/// nothing here reads or writes the developer's `~/.gitconfig` or token store.
384
fn oa(directory: &Path, home: &Path, args: &[&str]) -> String {
385
    let output = Command::new(env!("CARGO_BIN_EXE_oa"))
386
        .current_dir(directory)
387
        .env("HOME", home)
388
        .env_remove("OPENAGENTS_TOKEN")
389
        .args(args)
390
        .output()
391
        .expect("oa runs");
392
    assert!(
393
        output.status.success(),
394
        "oa {args:?} exited {:?}: {}",
395
        output.status.code(),
396
        String::from_utf8_lossy(&output.stderr)
397
    );
398
    String::from_utf8_lossy(&output.stdout).to_string()
399
}
400
401
/// What `oa auth status` says about the local helper, through the real binary.
402
fn reported_local_helper(directory: &Path, home: &Path) -> bool {
403
    let status = oa(
404
        directory,
405
        home,
406
        &["--api-url", OFFLINE_ORIGIN, "auth", "status", "--json"],
407
    );
408
    let parsed: serde_json::Value = serde_json::from_str(&status).expect("status is JSON");
409
    parsed["git_helper"]["local"]
410
        .as_bool()
411
        .expect("status reports the local helper")
412
}
413
414
/// `setup-git` writes a helper and `auth status` then reports it configured.
415
///
416
/// Round-tripped through the real binary rather than asserted as a string,
417
/// because the defect was exactly a disagreement between the two halves:
418
/// `setup-git` wrote `current_exe()` canonicalized, `status` compared the
419
/// config against *its own* `current_exe()`, and a helper installed by one
420
/// build read as absent to another. Either half alone looked right.
421
#[test]
422
fn setup_git_and_auth_status_agree_about_the_helper() {
423
    let home = tempfile::tempdir().unwrap();
424
    let directory = tempfile::tempdir().unwrap();
425
    init_repository(directory.path());
426
    let key = format!("credential.{OFFLINE_ORIGIN}.helper");
427
428
    assert!(
429
        !reported_local_helper(directory.path(), home.path()),
430
        "the checkout starts unconfigured"
431
    );
432
433
    oa(
434
        directory.path(),
435
        home.path(),
436
        &["--api-url", OFFLINE_ORIGIN, "auth", "setup-git", "--local"],
437
    );
438
439
    // The value that actually landed in git config, in full.
440
    let written = git(directory.path(), &["config", "--local", "--get-all", &key]);
441
    let values: Vec<&str> = written.trim_end_matches('\n').split('\n').collect();
442
    assert_eq!(
443
        values,
444
        vec![
445
            "",
446
            "!oa --api-url http://127.0.0.1:59999 auth git-credential"
447
        ],
448
        "{written}"
449
    );
450
451
    assert!(
452
        reported_local_helper(directory.path(), home.path()),
453
        "auth status called a helper it had just written absent: {written}"
454
    );
455
}
456
457
/// A helper this CLI did not write, but which works, reads as configured.
458
///
459
/// `!openagents …` is what the TypeScript CLI installs, and older builds of
460
/// this one wrote an absolute path. All three answer the same origin, so all
461
/// three are configured; the version this replaces recognised only a line
462
/// equal to its own path and called the other two absent, which is a false
463
/// statement about the machine the command is running on.
464
#[test]
465
fn a_helper_written_by_another_build_reads_as_configured() {
466
    let home = tempfile::tempdir().unwrap();
467
    let directory = tempfile::tempdir().unwrap();
468
    init_repository(directory.path());
469
    let key = format!("credential.{OFFLINE_ORIGIN}.helper");
470
471
    let install = |helper: &str| {
472
        git(directory.path(), &["config", "--local", "--unset-all", &key]);
473
        git(directory.path(), &["config", "--local", "--add", &key, helper]);
474
    };
475
476
    for helper in [
477
        // The TypeScript CLI's.
478
        &format!("!openagents --api-url {OFFLINE_ORIGIN} auth git-credential"),
479
        // An older build of this CLI's, and a moved one.
480
        &format!("!/opt/openagents/bin/oa --api-url {OFFLINE_ORIGIN} auth git-credential"),
481
        &format!("!'/a path/with spaces/oa' --api-url {OFFLINE_ORIGIN} auth git-credential"),
482
    ] {
483
        install(helper);
484
        assert!(
485
            reported_local_helper(directory.path(), home.path()),
486
            "auth status called `{helper}` absent"
487
        );
488
    }
489
490
    for helper in [
491
        // Somebody else's credential helper.
492
        "!gh auth git-credential",
493
        "osxkeychain",
494
        // Ours, but answering a different origin. Not configured for this one.
495
        "!oa --api-url https://staging.openagents.com auth git-credential",
496
        // A program that is not ours, whatever it is called after.
497
        &format!("!curl --api-url {OFFLINE_ORIGIN} auth git-credential"),
498
    ] {
499
        install(helper);
500
        assert!(
501
            !reported_local_helper(directory.path(), home.path()),
502
            "auth status called `{helper}` this CLI's helper"
503
        );
504
    }
505
}
506
362 507
/// A clone carries the helper on the command line, so a private repository
363 508
/// clones with no other credential present.
364 509
#[test]

@@ -549,3 +694,151 @@ fn the_legacy_profile_token_is_admitted_only_for_the_endpoint_it_names() {

549 694
    );
550 695
    assert!(staging.find_token().unwrap().is_some());
551 696
}
697
698
// ---------------------------------------------------------------------------
699
// #88 `repo create --source` / `--remote`
700
// ---------------------------------------------------------------------------
701
702
/// `--source` attaches the new repository to a checkout and names the push.
703
///
704
/// The remote is written for real and read back with `git remote get-url`, so
705
/// the claim is about the checkout rather than about the return value.
706
#[test]
707
fn create_source_attaches_the_remote_and_names_the_next_push() {
708
    let directory = tempfile::tempdir().unwrap();
709
    init_repository(directory.path());
710
    let url = format!("{ORIGIN}/AtlantisPleb/thing.git");
711
712
    let attached = attach_remote(ORIGIN, &url, directory.path(), "origin").unwrap();
713
    assert_eq!(attached.remote, "origin");
714
    assert_eq!(
715
        attached.next_push_arguments,
716
        vec!["push", "-u", "origin", "HEAD"]
717
    );
718
    assert_eq!(
719
        git(directory.path(), &["remote", "get-url", "origin"]).trim(),
720
        url,
721
        "the remote was not written into the checkout"
722
    );
723
    assert_eq!(
724
        attached.next_push_argv(directory.path()),
725
        vec![
726
            "git".to_string(),
727
            "-C".to_string(),
728
            directory.path().display().to_string(),
729
            "push".to_string(),
730
            "-u".to_string(),
731
            "origin".to_string(),
732
            "HEAD".to_string(),
733
        ]
734
    );
735
736
    // Running it again is the state being asked for, not a conflict.
737
    attach_remote(ORIGIN, &url, directory.path(), "origin").unwrap();
738
    assert_eq!(
739
        git(directory.path(), &["remote"]).lines().count(),
740
        1,
741
        "a second attach added a second remote"
742
    );
743
744
    // `--remote` names it something else, alongside the first.
745
    let second = attach_remote(ORIGIN, &url, directory.path(), "openagents").unwrap();
746
    assert_eq!(second.remote, "openagents");
747
    assert_eq!(
748
        git(directory.path(), &["remote", "get-url", "openagents"]).trim(),
749
        url
750
    );
751
}
752
753
/// A directory that is quoted in the printed command stays one word.
754
#[test]
755
fn the_printed_next_push_can_be_pasted_back_into_a_shell() {
756
    let directory = tempfile::tempdir().unwrap();
757
    let spaced = directory.path().join("a checkout");
758
    std::fs::create_dir(&spaced).unwrap();
759
    init_repository(&spaced);
760
761
    let attached =
762
        attach_remote(ORIGIN, &format!("{ORIGIN}/a/b.git"), &spaced, "origin").unwrap();
763
    let line = attached.next_push_command(&spaced);
764
    assert!(line.starts_with("git -C '"), "{line}");
765
    assert!(line.ends_with("' push -u origin HEAD"), "{line}");
766
}
767
768
/// Three things `--source` refuses rather than does badly.
769
#[test]
770
fn attach_remote_refuses_what_it_must_not_do_silently() {
771
    let directory = tempfile::tempdir().unwrap();
772
    init_repository(directory.path());
773
    let url = format!("{ORIGIN}/AtlantisPleb/thing.git");
774
775
    // A URL off this origin. Attaching it would point the credential helper —
776
    // which answers with this origin's token — at somebody else's host.
777
    let elsewhere = attach_remote(
778
        ORIGIN,
779
        "https://github.com/AtlantisPleb/thing.git",
780
        directory.path(),
781
        "origin",
782
    )
783
    .unwrap_err();
784
    assert!(
785
        elsewhere.to_string().contains("OpenAgents repository URL"),
786
        "{elsewhere}"
787
    );
788
    assert_eq!(
789
        git(directory.path(), &["remote"]).trim(),
790
        "",
791
        "a refused attach still wrote a remote"
792
    );
793
794
    // A remote of that name already pointing somewhere else.
795
    git(
796
        directory.path(),
797
        &["remote", "add", "origin", "https://example.com/other.git"],
798
    );
799
    let taken = attach_remote(ORIGIN, &url, directory.path(), "origin").unwrap_err();
800
    assert!(taken.to_string().contains("did not overwrite"), "{taken}");
801
    assert_eq!(
802
        git(directory.path(), &["remote", "get-url", "origin"]).trim(),
803
        "https://example.com/other.git",
804
        "the existing remote was overwritten"
805
    );
806
807
    // A directory that is not a worktree.
808
    let bare = tempfile::tempdir().unwrap();
809
    let outside = attach_remote(ORIGIN, &url, bare.path(), "origin").unwrap_err();
810
    assert!(outside.to_string().contains("not a git worktree"), "{outside}");
811
812
    // A name git will not take as a remote.
813
    for name in ["", "-dash", "a..b", "trailing.", "a.lock", "with space"] {
814
        assert!(
815
            attach_remote(ORIGIN, &url, directory.path(), name).is_err(),
816
            "`{name}` was admitted as a remote name"
817
        );
818
    }
819
}
820
821
/// Both `--source` refusals are reachable before the repository is created.
822
///
823
/// `repo create` calls these two first for that reason: a refusal that has
824
/// already made a repository on the server is not a refusal, and the reader is
825
/// left with one they were never told how to push to.
826
#[test]
827
fn a_bad_source_or_remote_is_refusable_without_creating_anything() {
828
    let not_a_worktree = tempfile::tempdir().unwrap();
829
    let error = require_worktree(not_a_worktree.path()).unwrap_err();
830
    assert!(error.to_string().contains("not a git worktree"), "{error}");
831
832
    let directory = tempfile::tempdir().unwrap();
833
    init_repository(directory.path());
834
    require_worktree(directory.path()).expect("a checkout is a worktree");
835
836
    assert_eq!(validate_remote_name("origin").unwrap(), "origin");
837
    assert_eq!(validate_remote_name("openagents").unwrap(), "openagents");
838
    for name in ["", "bad name", "-dash", "a..b", "trailing.", "a.lock"] {
839
        assert!(
840
            validate_remote_name(name).is_err(),
841
            "`{name}` was admitted as a remote name"
842
        );
843
    }
844
}
crates/openagents-cli/tests/delegate_test.rs modified +226

@@ -507,3 +507,229 @@ async fn a_delegated_child_revokes_its_own_thread() {

507 507
        REVOCATION_HELD.as_millis()
508 508
    );
509 509
}
510
511
// ──────────────────────────────────────── the `--child-*` flags on `oa coder`
512
513
/// Parse a real `oa coder` command line and give back the child options a
514
/// fan-out started from it would run with.
515
///
516
/// The whole chain, argv first: clap parses the flags, `DelegationRequest`
517
/// carries them off the coder command, and `ChildOptions::resolve` settles
518
/// them. `oa coder --delegate` declared none of these flags, so every one of
519
/// them used to stop at the parser — or rather never reach it.
520
fn child_options_from(argv: &[&str]) -> openagents_cli::delegate::ChildOptions {
521
    use clap::Parser;
522
    let parsed = openagents_cli::cli::Cli::parse_from(argv);
523
    let Some(openagents_cli::cli::Commands::Coder(coder)) = parsed.command else {
524
        panic!("{argv:?} did not parse as `oa coder`");
525
    };
526
    let request = openagents_cli::delegate::DelegationRequest::from_coder(coder);
527
    openagents_cli::delegate::ChildOptions::resolve(
528
        request.child_model,
529
        request.child_command,
530
        request.child_config,
531
        request.child_ask,
532
    )
533
}
534
535
/// A stand-in that reports the argv and the `OPENCODE_CONFIG` it was started
536
/// with, in the claude lane's wire shape.
537
///
538
/// The marker is the point: if `--child-command` did not reach the child, the
539
/// `claude` on this machine ran instead and the marker is absent.
540
fn reporting_stand_in(name: &str) -> PathBuf {
541
    stand_in(
542
        name,
543
        r#"#!/bin/sh
544
printf '{"type":"result","is_error":false,"result":"MARK argv=%s config=%s END"}\n' \
545
  "$*" "${OPENCODE_CONFIG:-unset}"
546
"#,
547
    )
548
}
549
550
/// What one child of a fan-out configured by `argv` actually reported.
551
///
552
/// Under the shared guard: these start real child processes, and the streaming
553
/// test above asserts against a wall clock, so a fan-out running alongside it
554
/// is measured as its latency.
555
async fn one_child_reports(argv: &[&str]) -> String {
556
    let _exclusive = exclusive();
557
    let options = child_options_from(argv);
558
    let supervisor = DelegationSupervisor::new(1, "claude", None)
559
        .with_isolation(Isolation::Directory)
560
        .with_child_options(options);
561
    let (results, _) = run(&supervisor, "ignored", None).await;
562
    assert!(results[0].success, "the child failed: {}", results[0].output);
563
    let said = results[0].output.clone();
564
    assert!(
565
        said.contains("MARK"),
566
        "the stand-in did not run, so --child-command never reached the child: {said}"
567
    );
568
    said
569
}
570
571
/// `--child-config` reaches the child's environment.
572
///
573
/// This is the one with consequences: the CLI deliberately stores no provider
574
/// credential, so a harness config passed as `OPENCODE_CONFIG` is the only
575
/// route one has to a delegated child. Asserted against the environment of a
576
/// real process, not against the parsed flag.
577
#[tokio::test]
578
async fn child_config_reaches_a_real_child_process() {
579
    let harness = reporting_stand_in("config-reporting-claude");
580
    let config = std::env::temp_dir().join("oa-child-harness-config.json");
581
    std::fs::write(&config, "{}").unwrap();
582
583
    let said = one_child_reports(&[
584
        "oa",
585
        "coder",
586
        "--delegate",
587
        "--child-command",
588
        harness.to_str().unwrap(),
589
        "--child-config",
590
        config.to_str().unwrap(),
591
        "do the thing",
592
    ])
593
    .await;
594
595
    assert!(
596
        said.contains(&format!("config={}", config.display())),
597
        "OPENCODE_CONFIG did not reach the child: {said}"
598
    );
599
}
600
601
/// `--child-model` reaches the child's argument list, and `--child-ask`
602
/// changes the mode it is started in.
603
#[tokio::test]
604
async fn child_model_and_child_ask_reach_a_real_child_process() {
605
    let harness = reporting_stand_in("model-reporting-claude");
606
    let command = harness.to_str().unwrap();
607
608
    let said = one_child_reports(&[
609
        "oa",
610
        "coder",
611
        "--delegate",
612
        "--child-command",
613
        command,
614
        "--child-model",
615
        "claude-sonnet-4-5",
616
        "go",
617
    ])
618
    .await;
619
    assert!(
620
        said.contains("--model claude-sonnet-4-5"),
621
        "--child-model did not reach the child's argv: {said}"
622
    );
623
    // Without --child-ask a delegated child has nobody to ask, so it accepts
624
    // its own edits.
625
    assert!(
626
        said.contains("--permission-mode acceptEdits"),
627
        "{said}"
628
    );
629
630
    let asking = one_child_reports(&[
631
        "oa",
632
        "coder",
633
        "--delegate",
634
        "--child-command",
635
        command,
636
        "--child-ask",
637
        "go",
638
    ])
639
    .await;
640
    assert!(
641
        asking.contains("--permission-mode default"),
642
        "--child-ask did not change the mode the child was started in: {asking}"
643
    );
644
    assert!(
645
        !asking.contains("acceptEdits"),
646
        "--child-ask left the child accepting its own edits: {asking}"
647
    );
648
}
649
650
/// `--concurrency` on `oa coder` is the cap, under the name the TypeScript CLI
651
/// and `oa delegate` both use for it.
652
#[test]
653
fn concurrency_is_the_cap_on_the_coder_command_too() {
654
    use clap::Parser;
655
    for flag in ["--concurrency", "--max-parallel"] {
656
        let parsed =
657
            openagents_cli::cli::Cli::parse_from(["oa", "coder", "--delegate", flag, "3", "go"]);
658
        let Some(openagents_cli::cli::Commands::Coder(coder)) = parsed.command else {
659
            panic!("{flag} did not parse as `oa coder`");
660
        };
661
        assert_eq!(
662
            openagents_cli::delegate::DelegationRequest::from_coder(coder).max_parallel,
663
            Some(3),
664
            "{flag} did not reach the cap"
665
        );
666
    }
667
}
668
669
/// The `delegate` tool a session runs starts children on the session's own
670
/// `--child-*` flags.
671
///
672
/// `oa coder --child-config f` with no `--delegate` opens a session that can
673
/// still fan out — through `/delegate` or the model calling the tool — and that
674
/// path built its supervisor with no child options at all. The flag parsed,
675
/// said nothing, and the child never saw the file.
676
#[tokio::test]
677
async fn the_delegate_tool_carries_the_sessions_child_options() {
678
    let _exclusive = exclusive();
679
    let harness = reporting_stand_in("tool-reporting-claude");
680
    let config = std::env::temp_dir().join("oa-tool-harness-config.json");
681
    std::fs::write(&config, "{}").unwrap();
682
683
    let options = child_options_from(&[
684
        "oa",
685
        "coder",
686
        "--child-command",
687
        harness.to_str().unwrap(),
688
        "--child-config",
689
        config.to_str().unwrap(),
690
        "--child-model",
691
        "claude-sonnet-4-5",
692
    ]);
693
694
    // A directory of its own rather than this checkout: the tool's children
695
    // work where the session works, and a temporary directory keeps this test
696
    // from making a git worktree of the whole repository.
697
    let cwd = tempfile::tempdir().unwrap();
698
    let report = openagents_cli::delegate::fanout_for_tool(
699
        "do the thing",
700
        1,
701
        "claude",
702
        None,
703
        options,
704
        Some(cwd.path().to_path_buf()),
705
    )
706
    .await;
707
708
    assert!(
709
        report.contains("MARK"),
710
        "--child-command never reached the tool's child: {report}"
711
    );
712
    assert!(
713
        report.contains(&format!("config={}", config.display())),
714
        "OPENCODE_CONFIG never reached the tool's child: {report}"
715
    );
716
    assert!(
717
        report.contains("--model claude-sonnet-4-5"),
718
        "--child-model never reached the tool's child: {report}"
719
    );
720
}
721
722
/// A `--child-*` flag the session's lane cannot honour is said, not dropped.
723
#[tokio::test]
724
async fn the_delegate_tool_refuses_a_flag_its_lane_cannot_honour() {
725
    let options = child_options_from(&["oa", "coder", "--child-model", "gpt-5"]);
726
    // ox-alpha children run on the grant the server issues, which pins the
727
    // model. There is no honouring `--child-model` there.
728
    let report =
729
        openagents_cli::delegate::fanout_for_tool("go", 1, "ox-alpha", None, options, None).await;
730
    assert!(
731
        report.starts_with("No children were started:"),
732
        "the tool ran a fan-out without the model it was given: {report}"
733
    );
734
    assert!(report.contains("--child-model"), "{report}");
735
}

This page updates live while a promote is in flight · changelog