Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a66ec3b
docs: add PRD for GitHub-integrated minimald sessions
claude Jul 20, 2026
23c82c9
docs: clarify GitHub App auth roles in sessions PRD
claude Jul 20, 2026
c7c5a92
docs: revise sessions PRD to daemon-mediated GitHub access
claude Jul 21, 2026
555e42a
feat(github): scaffold GitHub session domain types crate
norrietaylor Jul 25, 2026
6d6963c
feat(minimal): run attach ssh as a waited child, mirroring its exit
norrietaylor Jul 25, 2026
354128e
feat(github): add on-disk GrantStore for GitHub auth grants
norrietaylor Jul 25, 2026
2d5edd8
feat(mfile): add [session.github] block for repo pre-priming and scopes
norrietaylor Jul 25, 2026
87192aa
feat(minimald-rpc): add GitHub oneshot RPC wire contracts
norrietaylor Jul 25, 2026
d13df2b
fix(mfile): sync Cargo.lock with the github crate dependency
norrietaylor Jul 25, 2026
36360af
feat(github): add test-support mock GitHub server and github-mock bin
norrietaylor Jul 25, 2026
26fcbf3
feat(github): add OAuth device-flow client behind the client feature
norrietaylor Jul 25, 2026
026d95d
feat(github): add GitHub REST client (user, installation, PRs)
norrietaylor Jul 25, 2026
4bf2309
feat(github): add leak-proof daemon-side git working-tree ops
norrietaylor Jul 25, 2026
2b59b15
feat(github): expose the gitops module from the crate root
norrietaylor Jul 25, 2026
2acab30
style(github): apply rustfmt to the gitops module
norrietaylor Jul 26, 2026
1f43818
feat(github): add token-refresh state machine with write-ahead rotation
norrietaylor Jul 26, 2026
a920fd9
feat(minimald): wire GithubService into daemon server state
norrietaylor Jul 26, 2026
5899e71
feat(minimald): add GitHub authz choke point over session attrs
norrietaylor Jul 26, 2026
5a21e1c
feat(minimald): add git% facade verb for in-sandbox min git
norrietaylor Jul 26, 2026
ed7dd48
fix(minimald): close origin-redirect exfiltration in the git% facade
norrietaylor Jul 26, 2026
34d24bb
fix(minimald): isolate the git% facade token leg from sandbox config
norrietaylor Jul 26, 2026
c3accc6
fix(minimald): route git% clone through the daemon-private mirror
norrietaylor Jul 26, 2026
631d124
fix(github): deny global/system git config to daemon-side git children
norrietaylor Jul 26, 2026
ca334e8
feat(minimal): add min github login|status|logout commands
norrietaylor Jul 28, 2026
3c7c0a4
feat(minimald): implement GitHub auth RPC handlers (login/status/logout)
norrietaylor Jul 28, 2026
5365377
fix(github): deny system config and guard ref-name option injection
norrietaylor Jul 28, 2026
ec57571
fix(minimald): keep daemon git out of sandbox-writable worktree config
norrietaylor Jul 28, 2026
82c4890
Merge remote-tracking branch 'origin/main' into claude/minimald-githu…
norrietaylor Jul 28, 2026
32c69c0
fix(minimal): propagate attach ExitStatus from bare-min default path
norrietaylor Jul 28, 2026
2135a7e
fix(minimald-rpc): correct 3-way merge of GitHub + DiagBundle RPC sec…
norrietaylor Jul 28, 2026
b8173cb
fix(minimald-rpc): return Result from GitHub RPC handlers for serve! …
norrietaylor Jul 28, 2026
45feda7
fix(github): wrap cli.command test patterns in Some; drop dead want_b…
norrietaylor Jul 28, 2026
36ba750
Merge branch 'main' into claude/minimald-github-prd-r6ew53
norrietaylor Jul 29, 2026
8ebb454
Merge remote-tracking branch 'origin/main' into claude/minimald-githu…
norrietaylor Jul 31, 2026
2a0eb6a
fix(github): gate the no-token disk assertion on test-support
norrietaylor Jul 31, 2026
07ee5b5
docs(AGENTS.md): add the github crate to the crate map
norrietaylor Jul 31, 2026
ea3ad48
fix(minimald-rpc): decode a daemon error as Err, not a blank Ok
norrietaylor Jul 31, 2026
886e143
test(minimald): assert the install URL against the mock's base
norrietaylor Jul 31, 2026
3c56f61
feat(github): bake the GitHub App client id in at build time
norrietaylor Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ on:

env:
CARGO_TERM_COLOR: always
# The GitHub App client id baked into every binary that links the `github`
# crate (crates/github/build.rs registers the rebuild trigger;
# crates/github/src/config.rs reads it via `option_env!`). Set at workflow
# level so it reaches every build job — including the minimald-linux-*
# binaries that build-release-initramfs repacks as the guest daemon, which
# boots with an empty environment and so could not be configured any other
# way.
#
# A repo VARIABLE, not a secret: a GitHub App client id is public (the
# device flow is a public-client flow with no client secret) and shipping it
# inside the binary discloses nothing. Never move this to `secrets` — a
# masked value would be unreadable in build logs for no benefit.
#
# Unset resolves to the empty string, which the crate treats as "no App
# configured": those builds fail closed with `Error::NotConfigured` exactly
# as they did before this seam existed.
MINIMAL_GITHUB_CLIENT_ID: ${{ vars.MINIMAL_GITHUB_CLIENT_ID }}

# Serialize release runs so two manual dispatches can't race to cut a release
# from different commits at the same time.
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The CLI reference overview is [docs/reference/cli.md](docs/reference/cli.md).

## Crate map

29 crates. One line each; the long-form map with plane assignments is in
30 crates. One line each; the long-form map with plane assignments is in
[docs/architecture.md](docs/architecture.md) §3.

| Crate | Role |
Expand All @@ -38,6 +38,7 @@ The CLI reference overview is [docs/reference/cli.md](docs/reference/cli.md).
| `common` | Common types and utilities (e.g. `SpecHash`) used across the codebase. |
| `decode` | Evaluates a Nickel config layer into in-memory packages/profiles/stacks. |
| `diagnostics` | App-agnostic machinery for diagnostic support bundles. |
| `github` | Daemon-held GitHub auth (device flow, grants, refresh) and leak-proof git/REST ops. |
| `graph` | In-memory dependency graph; its `planner` module orders builds. |
| `lcache` | Local cache of built artifacts, keyed by `SpecHash`. |
| `mctx` | Top-level 'minimal context' API tying configuration, decoding, graph, and cache together. |
Expand Down
20 changes: 20 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"crates/common",
"crates/decode",
"crates/diagnostics",
"crates/github",
"crates/graph",
"crates/mctx",
"crates/mfile",
Expand Down Expand Up @@ -164,6 +165,7 @@ thiserror = "2"
url = "2" # keep in sync with reqwest
uuid = { version = "1.23", features = ["v4", "v7", "serde"] }
vt100-ctt = { version = "0.17", default-features = false }
zeroize = "1.9"
zstd = "0.13"

moka = { version = "0.12", default-features = false, features = ["sync"] }
Expand All @@ -185,6 +187,7 @@ checkouts = { path = "crates/checkouts" }
common = { path = "crates/common" }
decode = { path = "crates/decode" }
diagnostics = { path = "crates/diagnostics" }
github = { path = "crates/github" }
mctx = { path = "crates/mctx" }
minimald-rpc = { path = "crates/minimald-rpc" }
mlog = { path = "crates/mlog" }
Expand Down
75 changes: 75 additions & 0 deletions crates/github/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
[package]
name = "github"
version = "0.0.1"
license = "MIT OR Apache-2.0"
edition.workspace = true
publish.workspace = true

[features]
# DEFAULT features are pure domain types with no I/O and no reqwest, so cheap
# consumers (mfile, minimal) can depend on this crate without pulling an HTTP
# stack.
default = []

# CLIENT gates the GitHub REST client (`rest` module) and the OAuth
# device-flow client (`device_flow` module) behind an explicit opt-in, so
# `mfile`/`minimal` keep depending on this crate without pulling in reqwest.
# See the `reqwest` dependency note below for why no TLS backend feature is
# requested directly here. `dep:tokio` is `device_flow`'s poll-loop backoff
# sleep (`tokio::time::sleep`); it is the same optional dependency `test-support`
# already declares below.
client = ["dep:reqwest", "dep:tokio"]

# TEST-SUPPORT ships a programmable, in-process mock GitHub (`testing` module)
# plus the `github-mock` binary: an OAuth device-flow + REST fake and an
# auth-enforcing git smart-HTTP endpoint (backed by `git http-backend`) used to
# exercise the daemon's auth/refresh/git paths without touching real GitHub.
#
# It is hand-rolled on the workspace tokio TCP stack, so it adds NO new external
# runtime dependency beyond crates already in the lockfile (tokio, serde_json,
# base64, tempfile). No production code depends on this feature.
test-support = ["dep:tokio", "dep:base64", "dep:tempfile"]

[dependencies]
thiserror.workspace = true
url.workspace = true
zeroize.workspace = true

# For the on-disk grant store (`store.rs`): JSON encoding plus timestamps for
# token expiry/refresh bookkeeping. Local filesystem I/O only — no network
# stack, so this stays compatible with the "no HTTP stack" promise above.
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true

# Enabled only by `test-support` (see the feature note above).
base64 = { workspace = true, optional = true }
tempfile = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }

# Enabled only by `client` (see the feature note above). Deliberately no TLS
# backend feature (`rustls`/`native-tls`) requested here: every rustls-backed
# provider in the workspace's dependency set (`ring`, `aws-lc-rs`) compiles a
# C/asm crypto core via `cc`, which this build environment cannot do. Instead
# this rides on Cargo's per-binary feature unification — whichever sibling
# crate already linked into the real daemon binary (e.g. `rcache`'s GCS
# client, which needs HTTPS regardless) enables a TLS feature on this same
# `reqwest`, and that feature applies here too since it's one dependency
# graph. In an isolated `cargo build -p github --features client`, this
# yields an HTTP-only client — sufficient for the mock-backed tests below,
# since none of them talk TLS. `form` adds the URL-encoded request bodies the
# OAuth device-flow endpoints expect (`login/device/code`,
# `login/oauth/access_token`); it is pure-Rust (`serde`/`serde_urlencoded`),
# so it adds no C/asm compile step.
reqwest = { workspace = true, optional = true, features = ["form"] }

[dev-dependencies]
tempfile.workspace = true

# The out-of-process mock used by scripts/session-e2e.sh. Compiled only when the
# `test-support` feature is on, so the default build (and its consumers) never
# builds a binary or pulls the tokio HTTP stack.
[[bin]]
name = "github-mock"
path = "src/bin/github-mock.rs"
required-features = ["test-support"]
20 changes: 20 additions & 0 deletions crates/github/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! Bake the shipped GitHub App client id into the crate at build time.
//!
//! `config.rs` reads the value with `option_env!(CLIENT_ID_BUILD_ENV)`, which is
//! resolved when *this crate* is compiled. Cargo does not otherwise know that
//! the compilation depends on that variable, so a changed id would be served
//! from a stale build cache; the `rerun-if-env-changed` below is what makes the
//! injection reliable.
//!
//! Nothing here fails an unset build: a tree built without the variable ships an
//! unconfigured client id and every GitHub op fails closed with
//! `Error::NotConfigured`, exactly as it did before this seam existed.

/// Build-time environment variable carrying the GitHub App client id. Kept in
/// step with `config::CLIENT_ID_BUILD_ENV`.
const CLIENT_ID_BUILD_ENV: &str = "MINIMAL_GITHUB_CLIENT_ID";

fn main() {
println!("cargo::rerun-if-env-changed={CLIENT_ID_BUILD_ENV}");
println!("cargo::rerun-if-changed=build.rs");
}
156 changes: 156 additions & 0 deletions crates/github/src/attrs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Codec between the GitHub domain types and `SessionConfig.attrs` (spec R7.1).
//!
//! Repo pre-priming and scope selection are carried first as free-form string
//! `attrs` (a `BTreeMap<String, String>` on the session config/record) and
//! promotable to typed fields later. This module is the one place that knows the
//! key names and the string encodings, so encode/decode stay in lockstep.
//!
//! Keys:
//! * `github.grant_id` — the reused/minted grant id (spec R6.4).
//! * `github.repos` — comma-separated `owner/repo[@branch[:base]]` specs (spec R2.1).
//! * `github.scopes` — the compact [`ScopeSet`] encoding (spec R5).

use std::collections::BTreeMap;
use std::str::FromStr;

use crate::error::Error;
use crate::scopes::ScopeSet;
use crate::types::{GrantId, RepoSpec};

/// `attrs` key for the grant id.
pub const ATTR_GRANT_ID: &str = "github.grant_id";
/// `attrs` key for the repo pre-priming list.
pub const ATTR_REPOS: &str = "github.repos";
/// `attrs` key for the resolved scope set.
pub const ATTR_SCOPES: &str = "github.scopes";

/// The GitHub-relevant slice of a session's `attrs`, decoded into typed values.
///
/// All fields are optional so a session with no GitHub involvement decodes to an
/// empty value and encodes to nothing.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GithubAttrs {
/// The reused or minted grant id, if any.
pub grant_id: Option<GrantId>,
/// The repositories to pre-prime.
pub repos: Vec<RepoSpec>,
/// The resolved scope set, if one was recorded.
pub scopes: Option<ScopeSet>,
}

impl GithubAttrs {
/// Whether this carries no GitHub configuration at all.
#[must_use]
pub fn is_empty(&self) -> bool {
self.grant_id.is_none() && self.repos.is_empty() && self.scopes.is_none()
}

/// Writes the present fields into `attrs`. Absent fields are left untouched;
/// an empty repo list writes no key (so it round-trips with `decode`).
pub fn encode_into(&self, attrs: &mut BTreeMap<String, String>) {
if let Some(grant_id) = &self.grant_id {
attrs.insert(ATTR_GRANT_ID.to_string(), grant_id.to_string());
}
if !self.repos.is_empty() {
let joined = self
.repos
.iter()
.map(RepoSpec::to_string)
.collect::<Vec<_>>()
.join(",");
attrs.insert(ATTR_REPOS.to_string(), joined);
}
if let Some(scopes) = &self.scopes {
attrs.insert(ATTR_SCOPES.to_string(), scopes.to_attr_value());
}
}

/// Reads the GitHub fields from `attrs`, parsing each value. Absent keys
/// yield the empty/`None` default; present-but-malformed values are an error.
pub fn decode(attrs: &BTreeMap<String, String>) -> Result<Self, Error> {
let grant_id = match attrs.get(ATTR_GRANT_ID) {
Some(value) => Some(GrantId::from_str(value)?),
None => None,
};
let repos = match attrs.get(ATTR_REPOS) {
Some(value) => value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(RepoSpec::from_str)
.collect::<Result<Vec<_>, _>>()?,
None => Vec::new(),
};
let scopes = match attrs.get(ATTR_SCOPES) {
Some(value) => Some(ScopeSet::from_attr_value(value)?),
None => None,
};
Ok(Self {
grant_id,
repos,
scopes,
})
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn round_trips_full_set() {
let original = GithubAttrs {
grant_id: Some(GrantId::new("grant-abc").unwrap()),
repos: vec![
"octocat/hello@feat/x:main".parse().unwrap(),
"my-org/api".parse().unwrap(),
],
scopes: Some(ScopeSet::defaults()),
};

let mut map = BTreeMap::new();
original.encode_into(&mut map);

assert_eq!(map.get(ATTR_GRANT_ID).unwrap(), "grant-abc");
assert_eq!(
map.get(ATTR_REPOS).unwrap(),
"octocat/hello@feat/x:main,my-org/api"
);

let decoded = GithubAttrs::decode(&map).unwrap();
assert_eq!(decoded, original);
}

#[test]
fn empty_encodes_to_nothing_and_round_trips() {
let empty = GithubAttrs::default();
assert!(empty.is_empty());
let mut map = BTreeMap::new();
empty.encode_into(&mut map);
assert!(map.is_empty());
assert_eq!(GithubAttrs::decode(&map).unwrap(), empty);
}

#[test]
fn preserves_unrelated_attrs() {
let mut map = BTreeMap::new();
map.insert("other.key".to_string(), "value".to_string());
GithubAttrs {
grant_id: Some(GrantId::new("g").unwrap()),
..Default::default()
}
.encode_into(&mut map);
assert_eq!(map.get("other.key").unwrap(), "value");
}

#[test]
fn decode_rejects_malformed_values() {
let mut map = BTreeMap::new();
map.insert(ATTR_REPOS.to_string(), "not a repo spec".to_string());
assert!(GithubAttrs::decode(&map).is_err());

let mut map = BTreeMap::new();
map.insert(ATTR_SCOPES.to_string(), "workflows:rw".to_string());
assert!(GithubAttrs::decode(&map).is_err());
}
}
Loading
Loading