errand-bot 0.1.0

Run a coding agent from a chat channel, in a sandbox it cannot escape.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
//! Opening a pull request for a session's work.
//!
//! Done by the daemon rather than by the agent, so what a pull request says
//! about where it came from is not left to a model's discretion. An
//! instruction in a prompt is advice: one was seen keeping the two lines that
//! read as useful and dropping the third as redundant. This composes the body
//! itself.
//!
//! The agent does hold a token, because reading issues and checking builds
//! needs one, so this is a route rather than a wall. What it buys is that the
//! ordinary path produces a correct description every time, and that the
//! daemon is the one that reports where the pull request went.

use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};

use serde::Serialize;
use serde_json::{Value, json};

use crate::config::schema::GithubConfig;
use crate::session::github::{SessionLinks, attribution_footer};

/// A repository on GitHub, as the API addresses it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
    /// The account or organisation the repository sits under.
    pub owner: String,
    /// The repository's own name.
    pub name: String,
}

/// Reads the owner and repository out of a remote URL.
///
/// Both forms are accepted because a project may have been cloned either way,
/// and the daemon has no say in which.
///
/// Returns None for anything that is not GitHub, since there is nothing
/// useful to do with it here.
pub fn parse_remote(url: &str) -> Option<Repo> {
    let trimmed = url.trim();
    let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);

    if let Some(rest) = trimmed
        .strip_prefix("https://")
        .or_else(|| trimmed.strip_prefix("http://"))
    {
        // An optional userinfo stands before the first slash, as in a token
        // embedded by a service that cloned on the agent's behalf.
        let rest = match rest.find('@') {
            Some(at) if rest[..at].find('/').is_none() => &rest[at + 1..],
            _ => rest,
        };
        let (host, path) = rest.split_once('/')?;
        if host != "github.com" {
            return None;
        }
        return owner_name(path);
    }

    let rest = trimmed.strip_prefix("ssh://").unwrap_or(trimmed);
    let rest = rest.strip_prefix("git@github.com")?;
    let rest = rest.strip_prefix(':').or_else(|| rest.strip_prefix('/'))?;
    owner_name(rest)
}

fn owner_name(path: &str) -> Option<Repo> {
    let (owner, name) = path.split_once('/')?;
    if owner.is_empty() || name.is_empty() || name.contains('/') {
        return None;
    }
    Some(Repo {
        owner: owner.to_owned(),
        name: name.to_owned(),
    })
}

/// What a command said.
#[derive(Debug, Clone, PartialEq)]
pub struct Ran {
    /// The command's exit status.
    pub code: i32,
    /// What it wrote to standard output.
    pub stdout: String,
    /// What it wrote to standard error.
    pub stderr: String,
}

/// Runs a command. Injected so tests need no repository and no network.
pub type Run = Arc<
    dyn Fn(
            Vec<String>,
            Option<String>,
            BTreeMap<String, String>,
        ) -> Pin<Box<dyn Future<Output = Ran> + Send>>
        + Send
        + Sync,
>;

/// Waits. Injected so a test does not sit through a fork appearing.
pub type Sleep = Arc<dyn Fn(u64) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;

/// One call to the GitHub API.
pub struct ApiCall {
    /// The HTTP method, as the API expects it.
    pub method: String,
    /// The credential the call is made with.
    pub token: String,
    /// The JSON body, for a call that carries one.
    pub body: Option<Value>,
}

/// What the API said.
pub struct ApiReply {
    /// The HTTP status.
    pub status: u16,
    /// The parsed JSON body, or an empty object when it was not JSON.
    pub body: Value,
}

/// Calls the GitHub API. Injected for the same reason.
pub type Api =
    Arc<dyn Fn(String, ApiCall) -> Pin<Box<dyn Future<Output = ApiReply> + Send>> + Send + Sync>;

/// What went wrong, in words worth posting into a thread.
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct PullRequestError(pub String);

/// What a session produced, and where it should go.
pub struct Request {
    /// How this host reaches GitHub, and who it pushes as.
    pub github: GithubConfig,
    /// The session's directory, which holds the repository rather than being
    /// one.
    pub project_path: String,
    /// Which repository in it, by directory name. Only needed when it holds
    /// several.
    pub repository: Option<String>,
    /// Title for the pull request.
    pub title: String,
    /// Who asked, and where the conversation is.
    pub requested_by: String,
    /// Where the conversation is, for the body to point back at.
    pub links: SessionLinks,
}

/// The body the daemon writes, which is the whole point of doing this here.
pub fn pull_request_body(summary: &str, requested_by: &str, links: &SessionLinks) -> String {
    format!(
        "{}\n\n---\n\n{}\n",
        summary.trim(),
        attribution_footer(requested_by, links)
    )
}

/// Pushes without the token ever reaching a command line.
///
/// A URL carrying it would appear in `ps` for anyone on the host, and in git's
/// own error output. A credential helper reads it from the environment
/// instead.
const TOKEN_VARIABLE: &str = "ERRAND_GH_TOKEN";
const CREDENTIAL_HELPER: &str =
    "!f() { echo \"username=x-access-token\"; echo \"password=$ERRAND_GH_TOKEN\"; }; f";

/// Settings a repository must not be allowed to supply.
///
/// These commands run on the host, outside the sandbox, against a tree the
/// session can write. Git treats a repository as a source of code as much as
/// of data: a hook, a credential helper, a filesystem monitor are all commands
/// it will run on the daemon's behalf. Naming each one on the command line
/// beats whatever the repository says, since a `-c` is read last.
///
/// Overriding named settings is a denylist, and a denylist against git is a
/// losing game: signature verification runs `gpg.program`, a textconv filter
/// runs its command, and the list grows with git. So this is only the first
/// layer. The push happens in a clone the session never wrote, and anything
/// that reads a commit reads it there too, where the repository's
/// configuration is gone rather than merely overridden. See [`push_work`].
const SAFE_CONFIG: &[&str] = &[
    "-c",
    "core.hooksPath=/dev/null",
    "-c",
    "core.fsmonitor=false",
    "-c",
    "core.pager=cat",
    "-c",
    "credential.helper=",
    "-c",
    "http.proxy=",
    "-c",
    "http.sslVerify=true",
    "-c",
    "protocol.ext.allow=never",
    // A signed commit is verified by running the configured "gpg", so a
    // repository that says what that program is runs it. Nothing here needs a
    // signature checked, so none is.
    "-c",
    "log.showSignature=false",
    "-c",
    "merge.verifySignatures=false",
];

/// The environment git is given, so the host's own configuration cannot join
/// in.
///
/// The system and global files are the caller's rather than the repository's,
/// but neither is wanted here: this runs one known operation and should behave
/// the same on every host. A prompt would hang a daemon nobody is watching.
const SAFE_ENV: &[(&str, &str)] = &[
    ("GIT_CONFIG_NOSYSTEM", "1"),
    ("GIT_CONFIG_GLOBAL", "/dev/null"),
    ("GIT_TERMINAL_PROMPT", "0"),
];

fn git(
    run: &Run,
    cwd: &str,
    args: &[&str],
    token: Option<&str>,
) -> Pin<Box<dyn Future<Output = Ran> + Send>> {
    let mut env: BTreeMap<String, String> = SAFE_ENV
        .iter()
        .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
        .collect();
    if let Some(token) = token {
        env.insert(TOKEN_VARIABLE.to_owned(), token.to_owned());
    }
    let mut command = vec!["git".to_owned()];
    command.extend(SAFE_CONFIG.iter().map(|setting| (*setting).to_owned()));
    command.extend(args.iter().map(|argument| (*argument).to_owned()));
    run(command, Some(cwd.to_owned()), env)
}

/// Refuses a repository whose git directory is not inside it.
///
/// git will follow a `gitdir:` pointer, a symlink, or an alternate out of the
/// tree, and the daemon then reads and pushes whatever is at the other end. So
/// git is asked where it would actually look, and an answer outside the
/// repository is refused rather than operated on. `--absolute-git-dir`
/// resolves a path and runs nothing the repository could name.
async fn assert_contained(run: &Run, repo: &str) -> Result<(), PullRequestError> {
    let dir = git(run, repo, &["rev-parse", "--absolute-git-dir"], None).await;
    if dir.code != 0 {
        return Err(PullRequestError(format!(
            "there is no git repository at {repo}"
        )));
    }
    let git_dir = dir.stdout.trim();
    let inside = git_dir == format!("{repo}/.git")
        || git_dir.starts_with(&format!("{repo}/.git/"))
        || git_dir == repo
        || git_dir.starts_with(&format!("{repo}/"));
    if !inside {
        return Err(PullRequestError(
            "this project's git directory is outside it, so it is not one the daemon will open"
                .to_owned(),
        ));
    }
    Ok(())
}

/// The branch the work is on, refusing a detached head.
pub async fn current_branch(run: &Run, project_path: &str) -> Result<String, PullRequestError> {
    let head = git(
        run,
        project_path,
        &["rev-parse", "--abbrev-ref", "HEAD"],
        None,
    )
    .await;
    if head.code != 0 {
        return Err(PullRequestError(format!(
            "git could not read a branch in {project_path}"
        )));
    }
    let branch = head.stdout.trim();
    if branch == "HEAD" {
        return Err(PullRequestError(
            "this project has no branch checked out".to_owned(),
        ));
    }
    Ok(branch.to_owned())
}

/// The upstream this work came from, read from the remote the agent cloned.
pub async fn upstream(run: &Run, project_path: &str) -> Result<Repo, PullRequestError> {
    let remote = git(run, project_path, &["remote", "get-url", "origin"], None).await;
    if remote.code != 0 {
        return Err(PullRequestError(
            "this project has no origin remote to open a pull request against".to_owned(),
        ));
    }
    parse_remote(&remote.stdout).ok_or_else(|| {
        PullRequestError(format!(
            "origin is not a GitHub remote: {}",
            remote.stdout.trim()
        ))
    })
}

/// Whether a directory is a working tree the daemon will operate on.
///
/// A real `.git` directory, and nothing else. A `.git` that is a file holds a
/// `gitdir:` line pointing elsewhere, and one that is a symlink points
/// elsewhere too; both are how a session makes the daemon read and push a
/// repository outside its own tree, which is somewhere on the host the session
/// cannot otherwise reach. A checkout errand made is an ordinary clone, whose
/// `.git` is a directory, so nothing legitimate is turned away.
fn is_work_tree(path: &Path) -> bool {
    std::fs::symlink_metadata(path.join(".git")).is_ok_and(|meta| meta.is_dir())
}

fn repositories_in(project_path: &str) -> Vec<String> {
    let Ok(entries) = std::fs::read_dir(project_path) else {
        return Vec::new();
    };
    let mut names: Vec<String> = entries
        .flatten()
        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
        .map(|entry| entry.file_name().to_string_lossy().into_owned())
        .filter(|name| is_work_tree(&Path::new(project_path).join(name)))
        .collect();
    names.sort();
    names
}

/// Finds the working tree the pull request is for.
///
/// A session's directory is not itself a repository. Whatever the agent was
/// asked to work on is cloned into it, so the repository is normally one level
/// down, and looking only at the top would report that there is nothing to
/// open when there plainly is.
///
/// `named` picks between them, which a session reused under the same name for
/// a while needs.
pub fn find_repository(
    project_path: &str,
    named: Option<&str>,
) -> Result<String, PullRequestError> {
    if let Some(named) = named.filter(|name| !name.is_empty()) {
        if named.contains('/') || named == "." || named == ".." {
            return Err(PullRequestError(format!(
                "`{named}` is not the name of a repository in this session"
            )));
        }
        let chosen = Path::new(project_path).join(named);
        if !is_work_tree(&chosen) {
            return Err(PullRequestError(format!(
                "there is no repository called `{named}` in this session"
            )));
        }
        return Ok(chosen.display().to_string());
    }

    if is_work_tree(Path::new(project_path)) {
        return Ok(project_path.to_owned());
    }

    let found = repositories_in(project_path);
    match found.as_slice() {
        [one] => Ok(Path::new(project_path).join(one).display().to_string()),
        [] => Err(PullRequestError(
            "nothing in this session is a git repository yet, so there is nothing to open"
                .to_owned(),
        )),
        many => Err(PullRequestError(format!(
            "this session holds several repositories ({}), so say which one to open",
            many.join(", ")
        ))),
    }
}

/// Longest wait for a new fork to appear, and how often it is looked for.
pub const FORK_WAIT_MS: u64 = 30_000;
const FORK_POLL_MS: u64 = 1_000;

/// Reads a repository out of an API answer that describes one.
fn named(body: &Value) -> Option<Repo> {
    let owner = body.get("owner")?.get("login")?.as_str()?;
    Some(Repo {
        owner: owner.to_owned(),
        name: body.get("name")?.as_str()?.to_owned(),
    })
}

fn detail(body: &Value) -> String {
    body.get("message")
        .and_then(Value::as_str)
        .map_or_else(|| "GitHub refused it".to_owned(), str::to_owned)
}

fn first_line(text: &str) -> String {
    text.trim()
        .split_once('\n')
        .map_or(text.trim(), |(first, _)| first)
        .to_owned()
}

/// The bot's fork of the upstream, once it is there to push to.
///
/// Where it lands is read from GitHub rather than assumed. The name is not
/// always the upstream's, since an account already holding a repository of
/// that name gets the fork under a different one, and the owner is a login,
/// which is not what the configured author name has to be.
///
/// Waiting is the other half. Forking is asynchronous and answered before it
/// has finished, and pushing inside that window fails as though the repository
/// did not exist.
async fn fork_of(
    api: &Api,
    token: &str,
    target: &Repo,
    sleep: &Sleep,
) -> Result<Repo, PullRequestError> {
    let made = api(
        format!("/repos/{}/{}/forks", target.owner, target.name),
        ApiCall {
            method: "POST".to_owned(),
            token: token.to_owned(),
            body: None,
        },
    )
    .await;
    if made.status >= 400 {
        // A repository the token cannot see is reported as missing rather than
        // as forbidden, so the two are worth naming together.
        let reach = if made.status == 403 || made.status == 404 {
            ", which the bot's token may not have access to"
        } else {
            ""
        };
        return Err(PullRequestError(format!(
            "could not fork {}/{}{}: {}",
            target.owner,
            target.name,
            reach,
            detail(&made.body)
        )));
    }

    let fork = named(&made.body).ok_or_else(|| {
        PullRequestError("GitHub accepted the fork but did not say where it put it".to_owned())
    })?;

    let deadline = Instant::now() + Duration::from_millis(FORK_WAIT_MS);
    loop {
        let there = api(
            format!("/repos/{}/{}", fork.owner, fork.name),
            ApiCall {
                method: "GET".to_owned(),
                token: token.to_owned(),
                body: None,
            },
        )
        .await;
        if there.status == 200 {
            return Ok(fork);
        }
        if Instant::now() >= deadline {
            return Err(PullRequestError(format!(
                "the fork {}/{} did not become available to push to",
                fork.owner, fork.name
            )));
        }
        sleep(FORK_POLL_MS).await;
    }
}

/// What the upstream merges into, which is not always `main`.
async fn default_branch(api: &Api, token: &str, repo: &Repo) -> String {
    let answer = api(
        format!("/repos/{}/{}", repo.owner, repo.name),
        ApiCall {
            method: "GET".to_owned(),
            token: token.to_owned(),
            body: None,
        },
    )
    .await;
    answer
        .body
        .get("default_branch")
        .and_then(Value::as_str)
        .unwrap_or("main")
        .to_owned()
}

/// Pushes the session's commit from a repository it never had a chance to
/// write.
///
/// The push is the one step that carries the token and opens a connection, and
/// it is the step git hangs the most on a repository's own configuration: a
/// `pre-push` hook, a credential helper named for a single URL, an `insteadOf`
/// that sends the whole thing somewhere else. A session owns its working tree,
/// so against that tree none of those can be trusted, and the last two cannot
/// be overridden from the command line at all.
///
/// So the commit is pushed from a bare repository made here, holding one ref
/// and a pointer to the session's objects. Objects are data and are only ever
/// read; the configuration, which is code, is left behind. The session never
/// learns of this directory and cannot write to it.
async fn push_work(
    run: &Run,
    project_path: &str,
    branch: &str,
    url: &str,
    token: &str,
) -> Result<String, PullRequestError> {
    let staging = tempfile::tempdir()
        .map_err(|error| PullRequestError(format!("could not prepare the push: {error}")))?;
    let repository = staging.path().join("repository.git");

    // A clone takes the refs and leaves the configuration: the copy gets a
    // fresh one, and hooks are never carried over. `--shared` borrows the
    // objects rather than copying them, so a long history costs nothing here,
    // and objects are read-only data in any case.
    let cloned = git(
        run,
        staging.path().to_str().unwrap_or_default(),
        &[
            "clone",
            "--shared",
            "--bare",
            "--quiet",
            project_path,
            &repository.display().to_string(),
        ],
        None,
    )
    .await;
    if cloned.code != 0 {
        return Err(PullRequestError(format!(
            "could not prepare the push: {}",
            first_line(&cloned.stderr)
        )));
    }

    let pushed = git(
        run,
        &repository.display().to_string(),
        &[
            "-c",
            &format!("credential.helper={CREDENTIAL_HELPER}"),
            "push",
            "--force-with-lease",
            url,
            &format!("refs/heads/{branch}:refs/heads/{branch}"),
        ],
        Some(token),
    )
    .await;
    if pushed.code != 0 {
        return Err(PullRequestError(format!(
            "could not push {branch}: {}",
            first_line(&pushed.stderr)
        )));
    }

    // Read here, where the configuration is the clone's own rather than the
    // session's. Reading it in the session's tree is what let a crafted commit
    // and a `gpg.program` in its config run on the host: `git log` verifies a
    // signature by running that program. The clone carries neither.
    let summary = git(
        run,
        &repository.display().to_string(),
        &["log", "-1", "--format=%b"],
        None,
    )
    .await;
    Ok(summary.stdout)
}

/// Opens the pull request, and returns where it is.
///
/// The fork is made first and pushed to, rather than pushing to the upstream:
/// a bot that never needs write access to somebody else's repository cannot
/// lose it.
pub async fn open_pull_request(
    request: &Request,
    run: &Run,
    api: &Api,
    sleep: &Sleep,
) -> Result<String, PullRequestError> {
    let project_path = find_repository(&request.project_path, request.repository.as_deref())?;
    assert_contained(run, &project_path).await?;
    let branch = current_branch(run, &project_path).await?;
    let target = upstream(run, &project_path).await?;
    let fork = fork_of(api, &request.github.token, &target, sleep).await?;

    let summary = push_work(
        run,
        &project_path,
        &branch,
        &format!("https://github.com/{}/{}.git", fork.owner, fork.name),
        &request.github.token,
    )
    .await?;

    let created = api(
        format!("/repos/{}/{}/pulls", target.owner, target.name),
        ApiCall {
            method: "POST".to_owned(),
            token: request.github.token.clone(),
            body: Some(make_pull(
                &request.title,
                &fork.owner,
                &branch,
                &default_branch(api, &request.github.token, &target).await,
                &pull_request_body(&summary, &request.requested_by, &request.links),
            )),
        },
    )
    .await;
    if created.status >= 400 {
        return Err(PullRequestError(format!(
            "could not open the pull request: {}",
            detail(&created.body)
        )));
    }

    created
        .body
        .get("html_url")
        .and_then(Value::as_str)
        .map(str::to_owned)
        .ok_or_else(|| {
            PullRequestError("the pull request was created but GitHub did not say where".to_owned())
        })
}

fn make_pull(title: &str, owner: &str, branch: &str, base: &str, body: &str) -> Value {
    #[derive(Serialize)]
    struct NewPull<'a> {
        title: &'a str,
        head: String,
        base: &'a str,
        body: &'a str,
        maintainer_can_modify: bool,
    }
    json!(NewPull {
        title,
        head: format!("{owner}:{branch}"),
        base,
        body,
        maintainer_can_modify: true,
    })
}

/// Sleeps for real, between looks for a fork.
pub fn pause(ms: u64) -> Pin<Box<dyn Future<Output = ()> + Send>> {
    Box::pin(tokio::time::sleep(Duration::from_millis(ms)))
}

/// Runs a command on the host, for the daemon's own git operations.
pub fn run_command(
    command: Vec<String>,
    cwd: Option<String>,
    env: BTreeMap<String, String>,
) -> Pin<Box<dyn Future<Output = Ran> + Send>> {
    Box::pin(async move {
        let mut names = command.into_iter();
        let program = names.next().unwrap_or_default();
        let mut process = tokio::process::Command::new(&program);
        process.args(names);
        process.stdout(std::process::Stdio::piped());
        process.stderr(std::process::Stdio::piped());
        if let Some(cwd) = cwd {
            process.current_dir(cwd);
        }
        // The daemon's environment holds the bot token and the chat one. Only
        // what is named here crosses into git.
        process.env_clear();
        process.env("PATH", std::env::var("PATH").unwrap_or_default());
        process.env("HOME", std::env::var("HOME").unwrap_or_default());
        for (name, value) in env {
            process.env(name, value);
        }
        let output = match process.output().await {
            Ok(output) => output,
            Err(error) => {
                return Ran {
                    code: 127,
                    stdout: String::new(),
                    stderr: error.to_string(),
                };
            }
        };
        Ran {
            code: output.status.code().unwrap_or(127),
            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        }
    })
}

/// How the daemon names itself to GitHub.
///
/// GitHub refuses a request that carries no `User-Agent` with a 403 that
/// reads like a permissions failure. Deno's `fetch` sets one of its own, so
/// the TypeScript daemon never had to; `reqwest` sets none, and without this
/// every call fails, which looks from a thread like a repository the bot
/// cannot reach.
const USER_AGENT: &str = concat!("errand/", env!("CARGO_PKG_VERSION"));

/// The client the daemon calls GitHub with.
fn github_client() -> reqwest::Client {
    reqwest::Client::builder()
        .timeout(Duration::from_secs(30))
        .user_agent(USER_AGENT)
        .build()
        .unwrap_or_default()
}

/// Calls the GitHub REST API as the bot.
pub fn call_api(path: String, init: ApiCall) -> Pin<Box<dyn Future<Output = ApiReply> + Send>> {
    Box::pin(async move {
        let client = github_client();
        let method =
            reqwest::Method::from_bytes(init.method.as_bytes()).unwrap_or(reqwest::Method::GET);
        let mut request = client
            .request(method, format!("https://api.github.com{path}"))
            .header("Authorization", format!("Bearer {}", init.token))
            .header("Accept", "application/vnd.github+json")
            .header("X-GitHub-Api-Version", "2022-11-28");
        if let Some(body) = &init.body {
            request = request
                .header("Content-Type", "application/json")
                .body(body.to_string());
        }
        match request.send().await {
            Ok(response) => {
                let status = response.status().as_u16();
                let body = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
                ApiReply { status, body }
            }
            Err(error) => ApiReply {
                status: 0,
                body: json!({ "message": error.to_string() }),
            },
        }
    })
}

#[cfg(test)]
mod tests;