diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 416b98f4a..b7c865dbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,6 +246,23 @@ jobs: -f "$chart/ci/render-values.yaml" -f "$stub" > /dev/null done done + # A backend selector has no default, so an install states its cloud. + - name: helm template (an unset backend selector fails) + run: | + set -euo pipefail + check() { + chart="deploy/helm/$1" + if helm template test-release "$chart" \ + -f "$chart/ci/render-values.yaml" --set "$2=" > /dev/null 2>&1; then + echo "$1 renders with $2 unset" >&2 + exit 1 + fi + } + check engram blob.backend + check engram kek.provider + check engram secrets.backend + check engram-host-fleet blob.backend + check engram-host-fleet egress.caSource # Node prep's disk scheduler script against constructed sysfs trees, # and the chart default that turns it on. - name: host-fleet io scheduler diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd24a7f9..200d8ca29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,45 @@ starts with **Upgrade notes**: read them before you upgrade. The upgrade procedu ## [Unreleased] +### Upgrade notes + +- **Set the backend selectors in your Helm values before you upgrade.** They have no + default now, and `helm upgrade` fails at render, before it changes the release, when one + is unset. An install that relied on the old defaults sets `blob.backend: gcs`, + `kek.provider: gcp-kms` and `secrets.backend: gcp` (`engram`), and `blob.backend: gcs` + and `egress.caSource: gcp-secret-manager` (`engram-host-fleet`). The example overlays + set all of them. ([#1793]) +- **Upgrade the hosts before the control plane to use GitLab or Azure DevOps git.** A + session clones and pushes to them through the egress proxy of the new host agent. + ([#1793]) +- **A rollback past this release** makes a profile routed to LiteLLM, and the GitLab + review, fail until you upgrade again. Nothing else changes back. ([#1793]) + +### Added + +- **LiteLLM is a model router.** Point engrams at your own LiteLLM proxy and route Claude + Code and Codex sessions, titles and decisions through it. An admin picks the model for + each of those roles on Settings → Model routers. ([#1793]) +- **GitLab merge request review.** GitLab is a git forge: sessions clone and push with the + organization's token, which stays out of the VM, and a second review built-in reviews + merge requests. It needs GitLab 17.4 or later. An organization can use GitHub and GitLab + together. ([#1793]) +- An Azure DevOps connector: git over HTTPS, pull requests, work items and pipelines. It + has no review built-in. ([#1793]) + +### Changed + +- **Git forges, chat services and model routers are roles.** GitHub, Slack and OpenRouter + are implementations of them, and a new provider that fills a role gets the features built + on it. The pull request review and the Slack thread bot are unchanged. The site's "Add a + provider" page says what a provider implements. ([#1793]) +- A Decide block and smart routing work with any connected router that has a decision + model. A router with no decisions endpoint answers with a structured chat completion. + ([#1793]) +- A session gets git credentials only for a forge that its profile grants. ([#1793]) +- Every provider's webhooks arrive at `/api/v1/integrations//events`. The URLs of + the existing providers are the same. ([#1793]) + ## [0.13.0] - 2026-11-11 ### Upgrade notes @@ -389,3 +428,4 @@ The first tagged release. See the [#1784]: https://github.com/cortexapps/engrams/pull/1784 [#1788]: https://github.com/cortexapps/engrams/pull/1788 [#1789]: https://github.com/cortexapps/engrams/pull/1789 +[#1793]: https://github.com/cortexapps/engrams/pull/1793 diff --git a/DESIGN.md b/DESIGN.md index 9d0600dd8..ff3ce98da 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -503,6 +503,50 @@ pub trait SandboxBackend: Send + Sync { Firecracker has no "exec a command in a running guest" primitive. Every guest runs `engram-agentd` (`crates/engram-agentd`) — a small Rust binary the stage-1 init copies out of its reserved bundle slot to `/run/engram/engram-agentd` and execs (ADR 0080; nothing engrams-owned is baked into the rootfs beyond the shim). It listens on AF_VSOCK port 1024 and proxies exec / stdin / stdout for the host agent. `SandboxBackend::exec_stream` on the Firecracker backend connects to Firecracker's vsock proxy at ``, performs the `CONNECT 1024\n` → `OK \n` handshake, sends a `WireExecRequest`, and streams `WireExecEvent`s back. Full design in the [In-guest agent](#in-guest-agent-engram-agentd) section below. +### Integration roles (in `orchestrator/`) + +The product features that talk to an outside service go through three role seams. A +provider's code is under `orchestrator/src/providers//`; its connector JSON under +`orchestrator/src/connectors/` declares the hosts, the credential, the webhook, and the +actions. The guard for each fact is the test named beside it. + +| Role | Interface | Registry | Implementations | +|---|---|---|---| +| Git forge | `GitForge` (`forges/forge.ts`) | `forges/registry.ts` | GitHub, GitLab | +| Chat | `ChatProvider` (`chat/chat.ts`) | `chat/registry.ts` | Slack | +| Model router | `ModelRouterSpec` (`model-routers/registry.ts`) | the same file | OpenRouter, LiteLLM | + +- Code outside `providers/` names no provider. Four files are the exception, each with its + reason in the test (`only the listed files name a provider outside src/providers`). +- A role lookup with no implementation is an error or `null`, never another provider. A + stored row or block config from before the roles names no provider; it means the + provider whose spec has `legacyDefault`. +- Every provider's webhooks arrive on one route, `/api/v1/integrations/:provider/events`. + The connector's `webhook.ingress.scheme` selects the verifier in `automations/verify.ts`; + the provider supplies an `IntegrationEventSource` that says what a verified delivery + means. +- A built-in automation is a family: one builder holds the graph, and each provider fills + a variant (`ReviewVariant`, `ThreadsVariant`). Each variant is its own built-in row with + its own key and settings. The GitHub and Slack variants build the definitions that + shipped before the roles (`the PR review definition hash is unchanged`, + `the Slack threads definition hash is unchanged`); a changed hash stops every active run + of that built-in. +- A forge builds every link to itself (`GitForge.links`); the review API sends the links + and the web does not build them (`GetReview sends the forge's links for the pass and its + findings`). +- A forge that can check a commenter's permission and edit one comment has `typedReview`. + Its review variant uses the typed `review_facts` block; GitHub's variant keeps its + QuickJS facts step, because its definition hash must not change. +- A router with no native decisions endpoint answers a `decide` block with a structured + chat completion. A router with no built-in model for a role needs an admin's choice + before a feature that uses the role is offered. +- Git credentials follow the session's capabilities. A forge that mints short-lived tokens + (GitHub) answers git's credential helper in the guest. A forge with a static token + (GitLab, Azure DevOps) has git path rules in its connector, and the egress proxy adds + the token; the token is never in the guest. +- A `pr_ref` row is unique per `(provider, repo, pr_number)`. One connection per provider: + an org can use GitHub and GitLab together, but not two GitLab hosts. + --- ## Communication architecture diff --git a/crates/engram-coordinator/src/api/forge.rs b/crates/engram-coordinator/src/api/forge.rs index db4b7a1af..c5ebdefcb 100644 --- a/crates/engram-coordinator/src/api/forge.rs +++ b/crates/engram-coordinator/src/api/forge.rs @@ -1,8 +1,8 @@ //! ADRs 0023 and 0106 in-session credential-control seam. //! -//! One operation the in-guest `GIT_ASKPASS` helper invokes — mint a fresh -//! git clone/push credential — reachable over two transports that share the -//! same auth + dispatch core: +//! The in-guest `GIT_ASKPASS` helper asks for a fresh git clone/push +//! credential, and the harness asks for its OAuth credential, over two +//! transports that share one auth + dispatch core: //! //! - **HTTP** (`GET /sessions/:id/git-credential`): the ProcessBackend / //! `--mode=all` loopback path. Mounted OUTSIDE the deployment bearer @@ -15,100 +15,143 @@ //! deployment token. OAuth operations select no subject/provider from guest //! input: the coordinator resolves the durable session binding. //! -//! ADR 0056 P3 folded PR-open onto the egress inject+observe plane, so this -//! seam now carries ONLY the git credential — the one delivery the egress -//! interceptor can't header-inject (git speaks a credential-helper protocol, -//! not a header-authed HTTP API). +//! Only a forge whose integration mints a short-lived credential is served +//! here. A forge with a static token gets it from the egress proxy as a +//! header, so the token never enters the guest. +use std::collections::BTreeSet; use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::HeaderMap; use axum::Json; +use chrono::{DateTime, Utc}; use engram_core::traits::{CredentialHint, HarnessByteStream, Integration, ScopedCredential}; use engram_core::types::ids::SessionId; +use engram_core::types::Capability; use engram_harness_proto::{read_msg, write_msg, ForgeOp, ForgeRequest, ForgeResponse}; use serde::{Deserialize, Serialize}; use crate::error::ApiError; use crate::state::SharedState; -// ---- shared auth + dispatch core (HTTP + vsock) ------------------------ +// ---- shared core (HTTP + vsock) ----------------------------------------- -/// Why a forge request was denied. Maps onto an HTTP status (HTTP path) -/// or a `ForgeResponse::Error` message (vsock path). -enum Denied { - NoForge, - BadToken, +/// A git forge the session may use: a provider among the session's +/// capabilities whose integration serves a git host. +pub(crate) struct SessionForge { + /// The capability provider this forge was resolved for. + pub provider: String, + pub integration: Arc, + pub host: String, } -impl Denied { - fn message(&self) -> &'static str { - match self { - Self::NoForge => "no git forge is configured", - Self::BadToken => "invalid or missing forge token", +/// The session's forges, one per capability provider that serves a git host. +pub(crate) async fn session_forges(state: &SharedState, caps: &[Capability]) -> Vec { + let providers: BTreeSet<&str> = caps.iter().map(|c| c.provider.as_str()).collect(); + let mut forges = Vec::new(); + for provider in providers { + let Some(integration) = state + .integrations + .resolve(provider, &state.services.secrets) + .await + else { + continue; + }; + if let Some(host) = integration.git_forge_host() { + forges.push(SessionForge { + provider: provider.to_string(), + integration, + host, + }); } } + forges } -impl From for ApiError { - fn from(d: Denied) -> Self { - match d { - Denied::NoForge => ApiError::Unsupported(d.message().into()), - Denied::BadToken => ApiError::Unauthorized(d.message().into()), +/// Why the session got no git credential. +enum GitCredentialError { + /// The session holds no capability for a forge that serves the host. + NoForge(&'static str), + Failed(String), +} + +impl GitCredentialError { + fn into_message(self) -> String { + match self { + Self::NoForge(message) => message.to_string(), + Self::Failed(message) => message, } } } -/// Verify the per-session broker token (constant-time) and return the -/// configured forge. Layers the forge-configured check on top of the -/// shared [`crate::api::session_auth::authorize_broker_token`]. -async fn authorize( - state: &SharedState, - session: SessionId, - token: &str, -) -> Result, Denied> { - let integration = state - .integrations - .resolve("github", &state.services.secrets) - .await - .ok_or(Denied::NoForge)?; - if crate::api::session_auth::authorize_broker_token(state, session, token).await { - Ok(integration) - } else { - Err(Denied::BadToken) +impl From for ApiError { + fn from(error: GitCredentialError) -> Self { + match error { + GitCredentialError::NoForge(message) => ApiError::Forbidden(message.into()), + GitCredentialError::Failed(message) => ApiError::Internal(message), + } } } -/// Mint a credential, rejecting a host that doesn't match the forge. -/// -/// ADR 0056 (Plane A): the token is scoped to the session's bound capabilities -/// — read them from PG and hand them to the mint, which clamps the GitHub App -/// permissions to exactly what the profile granted (filtering to its own -/// provider). An empty set falls back to the provider's default scopes. -async fn op_fetch_credential( +/// A git credential: the username and password git sends as HTTP Basic. +struct GitCredential { + username: String, + password: String, + expires_at: DateTime, +} + +/// Mint the session's git credential for `host`. The forge is the one among +/// the session's capability providers that serves `host`; with no `host`, the +/// session's only forge. The mint clamps the credential to the capabilities, +/// so a session with no capability for a forge gets no credential from it. +async fn fetch_git_credential( state: &SharedState, session: SessionId, - integration: &Arc, host: Option, owner: Option, -) -> Result { +) -> Result { + use GitCredentialError::{Failed, NoForge}; + let host = host.filter(|s| !s.is_empty()); let caps = state .services .meta .get_session_capabilities(session) .await - .unwrap_or_default(); - // The git remote host (e.g. github.com); the integration validates it against - // its own served host. This is the one caller that legitimately sets it. + .map_err(|e| Failed(format!("read session capabilities: {e}")))?; + let mut forges = session_forges(state, &caps).await; + if let Some(host) = &host { + forges.retain(|forge| forge.host.eq_ignore_ascii_case(host)); + } + let forge = match forges.as_slice() { + [forge] => forge, + [] => return Err(NoForge("this session has no git forge for that host")), + _ => { + return Err(NoForge( + "this session has more than one git forge; name the host", + )) + } + }; let hint = CredentialHint { - served_host: host.filter(|s| !s.is_empty()), + served_host: host, owner: owner.filter(|s| !s.is_empty()), }; - integration - .mint_credential(&caps, &hint) - .await - .map_err(|e| format!("mint git credential: {e}")) + match forge.integration.mint_credential(&caps, &hint).await { + Ok(ScopedCredential::Basic { + username, + password, + expires_at, + }) => Ok(GitCredential { + username, + password, + expires_at, + }), + Ok(_) => Err(Failed(format!( + "{} minted a credential git cannot send as HTTP Basic", + forge.provider + ))), + Err(e) => Err(Failed(format!("mint git credential: {e}"))), + } } use crate::api::session_auth::bearer; @@ -130,9 +173,8 @@ pub struct GitCredentialResponse { pub expires_at: String, } -/// `GET /sessions/:id/git-credential` — mint a fresh installation -/// credential. The guest fetches per git op, so token expiry is -/// invisible in-guest. +/// `GET /sessions/:id/git-credential` — mint a fresh git credential. The +/// guest fetches one per git operation, so it never sees a token expire. pub async fn git_credential( State(state): State, Path(id): Path, @@ -141,24 +183,16 @@ pub async fn git_credential( ) -> Result, ApiError> { let token = bearer(&headers).ok_or_else(|| ApiError::Unauthorized("missing forge token".into()))?; - let integration = authorize(&state, id, &token).await?; - let cred = op_fetch_credential(&state, id, &integration, q.host, q.owner) - .await - .map_err(ApiError::Internal)?; - let ScopedCredential::Basic { - username, - password, - expires_at, - } = cred - else { - return Err(ApiError::Internal( - "github mint returned a non-basic credential".into(), + if !crate::api::session_auth::authorize_broker_token(&state, id, &token).await { + return Err(ApiError::Unauthorized( + "invalid or missing forge token".into(), )); - }; + } + let cred = fetch_git_credential(&state, id, q.host, q.owner).await?; Ok(Json(GitCredentialResponse { - username, - password, - expires_at: expires_at.to_rfc3339(), + username: cred.username, + password: cred.password, + expires_at: cred.expires_at.to_rfc3339(), })) } @@ -232,27 +266,14 @@ async fn process_request(state: &SharedState, req: ForgeRequest) -> ForgeRespons } match req.op { ForgeOp::FetchCredential { host, owner } => { - let integration = match state - .integrations - .resolve("github", &state.services.secrets) - .await - { - Some(integration) => integration, - None => { - return ForgeResponse::Error { - message: Denied::NoForge.message().into(), - } - } - }; - match op_fetch_credential(state, req.session_id, &integration, Some(host), owner).await - { - Ok(ScopedCredential::Basic { - username, password, .. - }) => ForgeResponse::Credential { username, password }, - Ok(_) => ForgeResponse::Error { - message: "github mint returned a non-basic credential".into(), + match fetch_git_credential(state, req.session_id, Some(host), owner).await { + Ok(cred) => ForgeResponse::Credential { + username: cred.username, + password: cred.password, + }, + Err(error) => ForgeResponse::Error { + message: error.into_message(), }, - Err(message) => ForgeResponse::Error { message }, } } ForgeOp::FetchOAuthCredential => match state.oauth.fetch_session(req.session_id).await { diff --git a/crates/engram-coordinator/src/api/sessions.rs b/crates/engram-coordinator/src/api/sessions.rs index 531ac09cc..3bda636c3 100644 --- a/crates/engram-coordinator/src/api/sessions.rs +++ b/crates/engram-coordinator/src/api/sessions.rs @@ -1809,33 +1809,17 @@ pub(crate) async fn inject_forge_env( session_id: SessionId, env: &mut HashMap, ) -> Result<(), ApiError> { - // ADR 0056 P2: forge env is injected when the session holds a capability for a - // provider that (a) resolves a mint engine AND (b) declares a git-forge host - // (`Integration::git_forge_host`). De-hardcodes the old `"github"` literal — any - // git-forge integration (github today; gitlab/gitea later) gets the askpass env. - // The manifest `[git]` block is retired — git binding rides the profile's caps. + // The askpass env goes to a session that holds a capability for a git forge. let caps = state .services .meta .get_session_capabilities(session_id) .await?; - // The session's bound caps that belong to a git-forge provider. - let mut git_caps: Vec<&engram_core::types::Capability> = Vec::new(); - let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); - for provider in caps.iter().map(|c| c.provider.as_str()) { - if !seen.insert(provider) { - continue; - } - let is_git_forge = state - .integrations - .resolve(provider, &state.services.secrets) - .await - .and_then(|e| e.git_forge_host()) - .is_some(); - if is_git_forge { - git_caps.extend(caps.iter().filter(|c| c.provider == provider)); - } - } + let forges = crate::api::forge::session_forges(state, &caps).await; + let git_caps: Vec<&engram_core::types::Capability> = caps + .iter() + .filter(|c| forges.iter().any(|f| f.provider == c.provider)) + .collect(); if git_caps.is_empty() { return Ok(()); } diff --git a/crates/engram-coordinator/src/integration_ops.rs b/crates/engram-coordinator/src/integration_ops.rs index 0556cdcf1..1ceac2590 100644 --- a/crates/engram-coordinator/src/integration_ops.rs +++ b/crates/engram-coordinator/src/integration_ops.rs @@ -14,7 +14,9 @@ use std::collections::HashMap; use std::time::Duration; -use engram_core::traits::{CredentialHint, ScopedCredential, SecretContext}; +use engram_core::traits::{ + render_header_template, CredentialHint, ScopedCredential, SecretContext, +}; use engram_core::types::SecretSchema; use engram_protocol::app; @@ -205,7 +207,7 @@ async fn apply_credential( } else { template }; - return Ok(builder.header(header_name, template.replace("{}", &token.secret))); + return Ok(builder.header(header_name, render_header_template(template, &token.secret))); } if spec.source == "mint" { // Draft fields (the connect sheet's pre-save "Test") build a one-shot @@ -257,7 +259,7 @@ async fn apply_credential( } else { inj.template.as_str() }; - b = b.header(header_name, template.replace("{}", &value)); + b = b.header(header_name, render_header_template(template, &value)); } Ok(b) } diff --git a/crates/engram-coordinator/src/state.rs b/crates/engram-coordinator/src/state.rs index 159e8e74c..a1fdf263a 100644 --- a/crates/engram-coordinator/src/state.rs +++ b/crates/engram-coordinator/src/state.rs @@ -326,8 +326,8 @@ pub enum SessionEvent { at: DateTime, }, /// ADR 0056: a third-party integration surfaced a typed asset/action - /// into the session. Subsumes the retired `PullRequestOpened` — an - /// opened PR is now `provider: "forge"`, `asset_kind: "pull_request"`. + /// into the session. An opened PR is the connector's provider (e.g. + /// `"github"`) with `asset_kind: "pull_request"`. /// `provider` + `asset_kind` discriminate; the web keys its renderer on /// that pair (with a generic fallback). The wire carries NO rendering /// instructions — presentation lives in the web/orchestrator layer @@ -335,7 +335,7 @@ pub enum SessionEvent { /// reads. Always emitted coordinator-side at the broker seam, never a /// guest self-report of arbitrary metadata. IntegrationAsset { - /// The integration that produced this, e.g. `"forge"`. Namespaces + /// The connector provider that produced this, e.g. `"github"`. Namespaces /// `asset_kind` so two providers can't collide. provider: String, /// Provider-scoped type, e.g. `"pull_request"`. diff --git a/crates/engram-coordinator/tests/api.rs b/crates/engram-coordinator/tests/api.rs index 87060fedd..8e0cdcb7c 100644 --- a/crates/engram-coordinator/tests/api.rs +++ b/crates/engram-coordinator/tests/api.rs @@ -102,6 +102,12 @@ async fn build_forge_app() -> ( }) .await .expect("seed session"); + meta.bind_session_capabilities( + session_id, + &[engram_core::types::Capability::parse("github:contents:read@acme/app").unwrap()], + ) + .await + .expect("bind capabilities"); let sandbox_dir = tempfile::tempdir().expect("sandbox tempdir").keep(); let forge = Arc::new(engram_git_dev::StaticGitHubIntegration::github( "ghs_test_xyz", @@ -187,6 +193,31 @@ async fn forge_git_credential_gated_by_broker_token() { assert_eq!(v["password"], "ghs_test_xyz"); } +#[tokio::test] +async fn forge_git_credential_needs_a_capability_for_the_host() { + let (app, sid, forge) = build_forge_app().await; + let get = |uri: String| { + app.clone().oneshot( + Request::get(uri) + .header("authorization", "Bearer broker-tok-123") + .body(Body::empty()) + .unwrap(), + ) + }; + let uri = format!("/api/v1/sessions/{sid}/git-credential"); + + // The session's forge serves github.com: a named host must match it. + let resp = get(format!("{uri}?host=GitHub.com")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let resp = get(format!("{uri}?host=gitlab.com")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // The mint is clamped to the session's own capabilities. + let minted = forge.recorded_mint_caps(); + assert_eq!(minted.len(), 1); + assert_eq!(minted[0][0].action, "contents:read"); +} + #[tokio::test] async fn forge_forward_runs_the_core_for_split_hosts() { // ADR 0023 split-mode forwarding: an FC host can't run the forge sink diff --git a/crates/engram-core/src/traits/integration.rs b/crates/engram-core/src/traits/integration.rs index cd917c04d..9bd54eb14 100644 --- a/crates/engram-core/src/traits/integration.rs +++ b/crates/engram-core/src/traits/integration.rs @@ -161,8 +161,42 @@ pub fn default_inject_header(cred: &ScopedCredential) -> Option { } } -/// Minimal standard base64 (RFC 4648, `+/`, `=` padding) — avoids a crate dep for -/// the one place engram-core needs it (the Basic `inject_header` default). +/// Render a connector's header template with the resolved secret. `{}` is the +/// secret itself. `{basic:USER}` is the HTTP Basic credential +/// base64(`USER:secret`), for a server that takes a token only as a Basic +/// password (git over HTTPS). The secret's own text is never scanned for a +/// placeholder. +pub fn render_header_template(template: &str, secret: &str) -> String { + const BASIC: &str = "{basic:"; + let mut out = String::with_capacity(template.len() + secret.len() * 2); + let mut rest = template; + loop { + let raw = rest.find("{}"); + let basic = rest + .find(BASIC) + .and_then(|at| Some((at, at + rest[at..].find('}')?))); + match (raw, basic) { + (Some(raw), basic) if basic.is_none_or(|(at, _)| raw < at) => { + out.push_str(&rest[..raw]); + out.push_str(secret); + rest = &rest[raw + 2..]; + } + (_, Some((at, end))) => { + let user = &rest[at + BASIC.len()..end]; + out.push_str(&rest[..at]); + out.push_str(&base64_std(format!("{user}:{secret}").as_bytes())); + rest = &rest[end + 1..]; + } + _ => { + out.push_str(rest); + return out; + } + } + } +} + +/// Standard base64 (RFC 4648, `+/`, `=` padding), so engram-core needs no +/// crate for the Basic credential. fn base64_std(input: &[u8]) -> String { const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(input.len().div_ceil(3) * 4); @@ -242,6 +276,35 @@ mod inject_header_tests { use super::*; use chrono::Utc; + #[test] + fn header_template_substitutes_the_secret() { + assert_eq!(render_header_template("Bearer {}", "tok"), "Bearer tok"); + assert_eq!(render_header_template("{}", "tok"), "tok"); + assert_eq!(render_header_template("static", "tok"), "static"); + } + + #[test] + fn header_template_renders_http_basic() { + // base64("oauth2:tok") and base64(":tok"). + assert_eq!( + render_header_template("Basic {basic:oauth2}", "tok"), + "Basic b2F1dGgyOnRvaw==" + ); + assert_eq!( + render_header_template("Basic {basic:}", "tok"), + "Basic OnRvaw==" + ); + } + + #[test] + fn header_template_never_scans_the_secret() { + assert_eq!( + render_header_template("{} {}", "{basic:x}"), + "{basic:x} {basic:x}" + ); + assert_eq!(render_header_template("{basic:u", "tok"), "{basic:u"); + } + #[test] fn base64_std_matches_rfc4648() { assert_eq!(base64_std(b""), ""); diff --git a/crates/engram-core/src/traits/mod.rs b/crates/engram-core/src/traits/mod.rs index 252440b7f..bb960a17c 100644 --- a/crates/engram-core/src/traits/mod.rs +++ b/crates/engram-core/src/traits/mod.rs @@ -27,8 +27,8 @@ pub use clock::{Clock, Entropy, OsEntropy, SystemClock}; pub use cloud::CloudBackend; pub use host_client::{HostClient, SessionFence}; pub use integration::{ - default_inject_header, CredentialHint, InjectHeader, Integration, MintFieldKind, - MintFieldSchema, MintKindDescriptor, ResolvedFields, ScopedCredential, + default_inject_header, render_header_template, CredentialHint, InjectHeader, Integration, + MintFieldKind, MintFieldSchema, MintKindDescriptor, ResolvedFields, ScopedCredential, }; pub use metadata::{ CreateDisposition, DisableEnabledImageOutcome, ExecLifecycleEventKind, ExecOutputStream, diff --git a/crates/engram-core/src/types/egress.rs b/crates/engram-core/src/types/egress.rs index 026ff01d5..f74f8d041 100644 --- a/crates/engram-core/src/types/egress.rs +++ b/crates/engram-core/src/types/egress.rs @@ -120,7 +120,7 @@ pub struct EgressSecretEntry { /// `secret` (its real value, host-side) before shipping; on an outbound /// request matching `allow_hosts`/`allow_host_patterns` (SNI) AND the /// request policy (`methods` + `path_globs`), the proxy adds -/// `header_name: `. The guest never +/// `header_name: `. The guest never /// holds the secret. Empty `methods`/`path_globs` = any. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EgressInjectEntry { diff --git a/crates/engram-core/src/types/integration.rs b/crates/engram-core/src/types/integration.rs index 120371d8e..1f4276757 100644 --- a/crates/engram-core/src/types/integration.rs +++ b/crates/engram-core/src/types/integration.rs @@ -186,7 +186,7 @@ pub struct IntegrationSecret { /// One Plane-B injection: on an outbound request to `hosts` matching the /// request policy (`methods` + `path_globs`), the proxy injects -/// `header_name: `. The +/// `header_name: `. The /// `secret_ref` is resolved by the coordinator's `SecretStore` host-side. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct IntegrationInject { diff --git a/crates/engram-egress-proxy/src/inject.rs b/crates/engram-egress-proxy/src/inject.rs index 3ff89ec1b..1c687a141 100644 --- a/crates/engram-egress-proxy/src/inject.rs +++ b/crates/engram-egress-proxy/src/inject.rs @@ -7,6 +7,8 @@ //! secret the guest never sees. (A request to an inject-gated host whose //! shape matches no entry is rejected by the caller.) +use engram_core::traits::render_header_template; + use crate::registry::InjectEntry; #[derive(Debug, PartialEq, Eq)] @@ -45,7 +47,7 @@ pub fn request_line(prefix: &[u8]) -> Option<(String, String)> { Some((method.to_string(), path.to_string())) } -/// Add each entry's `header_name: