Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
940f6be
docs: add mcp parity audit and implementation plan
leynier Oct 10, 2026
ce69b7e
feat: add an admin mcp access level and catalog v2
leynier Oct 10, 2026
a9c4d4d
refactor: reserve mcp catalog modules for each parity domain
leynier Oct 10, 2026
2446160
feat: run new workspace from prompt as a runtime operation
leynier Oct 10, 2026
c17002b
feat: expose runtime, workspace, inbox, orchestration and automation …
leynier Oct 10, 2026
3b1a61e
feat: support github, gitlab and azure devops pull requests in the ru…
leynier Oct 10, 2026
4fc4c2d
feat: add a runtime event journal, subscriptions and webhooks
leynier Oct 10, 2026
e0716dc
docs: document mcp parity, events and webhooks
leynier Oct 10, 2026
4e21a3a
fix: harden mcp parity against the security review findings
leynier Oct 10, 2026
e304e9e
fix: address review of desktop prompt origin, inbox events and webhoo…
leynier Oct 10, 2026
0dd25a5
fix: keep cancelled prompt workspaces, repo forge config and satellit…
leynier Oct 10, 2026
6fede4e
fix: pin callbacks past proxies and restore wake, mcp events pause an…
leynier Oct 10, 2026
031baa0
fix: guard remote azure merges and run prompt workspace setup at most…
leynier Oct 10, 2026
8b6842e
fix: resume expired event deliveries, decode azure names and keep exa…
leynier Oct 10, 2026
406c06c
fix: resume slow pull request detail generation and keep the webhook …
leynier Oct 10, 2026
a3bbe55
fix: send the thread of mobile pull request replies and edits
leynier Oct 10, 2026
02da82b
fix: split the request router for nested lifecycle stacks and address…
leynier Oct 10, 2026
bbc6bd8
feat: serve mcp sister skills from the edge and manage agent skills p…
leynier Oct 10, 2026
6d402f3
docs: require keeping cli and mcp skills up to date with every change
leynier Oct 10, 2026
f14dbea
fix: run agent skill installs as a runtime job that outlives the mcp …
leynier Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Describe the user-visible change.
- [ ] Relevant desktop build: `flutter build macos`, `flutter build windows`, or `flutter build linux`
- [ ] Landing checks, if applicable: `cd landing && bun run check`
- [ ] Added or updated tests that would catch regressions, or explained why tests were not needed
- [ ] CLI skills (`skills/`) and MCP skills (`edge/skills/`) reviewed for any changed command, runtime verb, or MCP tool, with versions bumped and `bun tool/skill_catalog.ts` rerun when they changed (see `AGENTS.md`, Agent And MCP Skills)

## AI Review Report

Expand Down
47 changes: 45 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ When planning is needed, use a spec-driven development flow. Do not jump straigh

## Pull Request Watch

- GitHub Watch and Fix execution belongs to the runtime when `pullRequestWatchExecutionV1` is advertised. Clients use the shared persisted watch and `pullRequestWatchChanged`; they MUST NOT also dispatch or merge locally. Keep the capability in the control file, `status.get`, and `mobile.hello`. See `docs/pull-request-watch.md` for execution and compatibility boundaries.
- Watch and Fix execution belongs to the runtime for GitHub when `pullRequestWatchExecutionV1` is advertised, and for GitHub, GitLab, and Azure DevOps when `pullRequestWatchExecutionV2` is. Clients use the shared persisted watch and `pullRequestWatchChanged`; they MUST NOT also dispatch or merge locally for a forge the runtime owns. Keep both capabilities, `pullRequestForgesV1`, `pullRequestAgentDispatchV1`, and `pullRequestStacksV1` in the control file and `status.get` (and the first three in `mobile.hello`). Forge work in the runtime goes through `ForgeProvider` (`rust/alera-cli/src/terminal_host/server/pull_request_forges/`), ported from the desktop providers; keep the shared fixtures in `test/fixtures/forges/` passing in both `forge_shared_fixtures_test.dart` and the Rust `fixture_tests.rs` when either side changes. See `docs/pull-request-watch.md` for execution and compatibility boundaries.

## Cloud Accounts And Mobile Push

Expand Down Expand Up @@ -278,10 +278,53 @@ When planning is needed, use a spec-driven development flow. Do not jump straigh

## Documentation Maintenance

- After every feature, refactor, fix, or infrastructure change, explicitly consider whether `AGENTS.md`, nested `AGENTS.md` files, `readme.md`, `docs/`, `.github/CONTRIBUTING.md`, `SECURITY.md`, or release documentation need updates.
- After every feature, refactor, fix, or infrastructure change, explicitly consider whether `AGENTS.md`, nested `AGENTS.md` files, `readme.md`, `docs/`, the agent skills in `skills/` and `edge/skills/`, `.github/CONTRIBUTING.md`, `SECURITY.md`, or release documentation need updates.
- If documentation does not need updates, mention that decision in the final summary or PR notes when the change is user-visible, architectural, process-related, release-related, or contributor-facing.
- Keep documentation aligned with implemented behavior. Do not document planned behavior as active behavior.

## Agent And MCP Skills

Alera ships two sets of skills that teach agents how to use it.

- **CLI skills:**
- They live in `skills/`: `alera-cli`, `alera-orchestration`, `alera-automations`, and `alera-agent-profiles`.
- They are for coding agents running in Alera terminals, and they name `alera` commands.
- `skills add` installs them at the runtime's build commit.
- **MCP sister skills:**
- They live in `edge/skills/`: `alera-mcp`, `alera-mcp-orchestration`, `alera-mcp-automations`, and `alera-mcp-agent-profiles`.
- They are for MCP clients, and they name MCP tools instead of commands.
- Only the edge serves them, through `list_skills` and `read_skill`. Runtimes MUST NOT bundle or serve them.

### When To Update Them

- Review both sets in the same change whenever a change touches a CLI command, runtime verb, or MCP tool. That means adding, removing, or renaming one, or changing its behavior, arguments, defaults, limits, or errors. Update every skill and reference the change makes stale.
- When a capability exists as both a CLI command and an MCP tool, update the CLI skill and its MCP sister together, so the two do not drift apart.

### Regenerating The MCP Catalog

- After editing `edge/skills/`, regenerate `edge/src/mcp/skill_catalog.json` by running `bun tool/skill_catalog.ts` from `edge/`.
- `edge/test/mcp_skills.test.ts` fails in any of these cases:
- the generated catalog is stale;
- a tool in the MCP catalog is not explained by any MCP skill;
- a skill names a tool that does not exist;
- a link inside a skill does not resolve.
- Adding an MCP tool therefore requires documenting it in an MCP skill.

### Bumping Versions

- **CLI skills:** changing any file under `skills/<name>/` MUST bump three things together:
1. the `metadata.version` in that skill's `SKILL.md`;
2. the matching constant in `rust/alera-cli/src/terminal_host/protocol.rs` (`CLI_SKILL_VERSION`, `ORCHESTRATION_SKILL_VERSION`, `AUTOMATIONS_SKILL_VERSION`, or `AGENT_PROFILES_SKILL_VERSION`);
3. the version and digest in `rust/alera-cli/tests/skill_version_matches_binary.rs`. The test prints the digest it expects.
- **Why it matters for CLI skills:** `alera skill status` and `check_agent_skills` decide whether an installed copy is current by its version.
- **MCP skills:** bump an MCP skill's `metadata.version` when its guidance changes in substance.

### Writing Them

- Skills describe implemented behavior only.
- Keep `SKILL.md` short and route to references, one workflow per reference, so agents load only what they need.
- Say in the PR summary which skills were reviewed and whether they changed.

## Nested Instructions

- `landing/AGENTS.md` applies under `landing/`.
Expand Down
10 changes: 10 additions & 0 deletions cloud/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ ALERA_WEB_GITHUB_CLIENT_SECRET=
ALERA_FCM_MODE=disabled
ALERA_FCM_PROJECT_ID=replace-me
ALERA_PUSH_DELIVERY_ENABLED=true
# Webhooks and MCP Events. Base64 of 32 random bytes; without it nothing is created or sent.
ALERA_WEBHOOK_SECRET_KEY=
# Set only while rotating ALERA_WEBHOOK_SECRET_KEY; decrypts existing secrets.
ALERA_WEBHOOK_PREVIOUS_SECRET_KEY=
# OpenAI MCP Events subscriptions. Defaults to false.
ALERA_MCP_EVENTS_ENABLED=false
# Background fan-out and delivery loop. Defaults to true.
ALERA_EVENT_WORKER_ENABLED=true
# Development only: allow webhook callbacks on HTTPS ports other than 443.
ALERA_WEBHOOK_ALLOW_ANY_PORT=false
ALERA_TOMBSTONE_PEPPER=replace-me-with-at-least-32-random-characters
ALERA_MAX_RUNTIMES=10
ALERA_MAX_MOBILE_DEVICES=5
Expand Down
1 change: 1 addition & 0 deletions cloud/Cargo.lock

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

1 change: 1 addition & 0 deletions cloud/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.104"
async-trait = "0.1.92"
aws-lc-rs = { version = "1.18.0", default-features = false, features = ["aws-lc-sys"] }
axum = { version = "0.8.9", features = ["http1", "json", "tokio"] }
base64 = "0.23.1"
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde", "std"] }
Expand Down
9 changes: 9 additions & 0 deletions cloud/migrations/0024_mcp_admin.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ALTER TABLE runtimes
DROP CONSTRAINT runtimes_mcp_access_check,
ADD CONSTRAINT runtimes_mcp_access_check
CHECK (mcp_access IN ('off', 'read', 'full', 'admin'));

ALTER TABLE mcp_calls
DROP CONSTRAINT mcp_calls_access_check,
ADD CONSTRAINT mcp_calls_access_check
CHECK (access IN ('read', 'execute', 'admin'));
70 changes: 70 additions & 0 deletions cloud/migrations/0025_domain_events.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- Runtime domain events forwarded for webhooks and MCP Events. Events are kept for 24 hours.
CREATE TABLE domain_events (
id UUID PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
runtime_id TEXT NOT NULL,
event_id TEXT NOT NULL,
seq BIGINT NOT NULL,
kind TEXT NOT NULL,
workspace_id TEXT,
project_id TEXT,
data JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
fanned_out_at TIMESTAMPTZ,
CONSTRAINT domain_events_runtime_event_key UNIQUE (runtime_id, event_id)
);
CREATE INDEX domain_events_received_idx ON domain_events (received_at);
CREATE INDEX domain_events_fanout_idx ON domain_events (received_at) WHERE fanned_out_at IS NULL;
CREATE INDEX domain_events_account_idx ON domain_events (account_id, received_at);

-- Webhooks created by a signed-in runtime and MCP Events subscriptions bound to an MCP grant.
CREATE TABLE event_subscriptions (
id TEXT PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
target_kind TEXT NOT NULL CONSTRAINT event_subscriptions_target_kind_check
CHECK (target_kind IN ('webhook', 'mcp_events')),
callback_url TEXT NOT NULL,
secret_ciphertext TEXT NOT NULL,
previous_secret_ciphertext TEXT,
previous_secret_until TIMESTAMPTZ,
kinds TEXT[] NOT NULL,
runtime_ids TEXT[] NOT NULL DEFAULT '{}',
all_runtimes BOOLEAN NOT NULL,
status TEXT NOT NULL CONSTRAINT event_subscriptions_status_check
CHECK (status IN ('active', 'stopped', 'revoked', 'expired')),
refresh_before TIMESTAMPTZ,
owner_grant_id UUID REFERENCES mcp_grants(id) ON DELETE CASCADE,
filter JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_runtime_id TEXT,
verified_at TIMESTAMPTZ,
last_delivery_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT event_subscriptions_owner_check
CHECK (target_kind = 'webhook' OR owner_grant_id IS NOT NULL)
);
CREATE INDEX event_subscriptions_account_idx ON event_subscriptions (account_id, status);
CREATE INDEX event_subscriptions_grant_idx ON event_subscriptions (owner_grant_id);

-- One delivery per subscription and event. `next_attempt_at` doubles as the lease
-- deadline while a worker holds the row in `sending`.
CREATE TABLE event_deliveries (
id UUID PRIMARY KEY,
subscription_id TEXT NOT NULL REFERENCES event_subscriptions(id) ON DELETE CASCADE,
event_id UUID NOT NULL REFERENCES domain_events(id) ON DELETE CASCADE,
status TEXT NOT NULL CONSTRAINT event_deliveries_status_check
CHECK (status IN ('pending', 'sending', 'delivered', 'stopped', 'dead')),
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL,
lease_until TIMESTAMPTZ,
last_status INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL,
delivered_at TIMESTAMPTZ,
CONSTRAINT event_deliveries_subscription_event_key UNIQUE (subscription_id, event_id)
);
CREATE INDEX event_deliveries_due_idx ON event_deliveries (next_attempt_at)
WHERE status IN ('pending', 'sending');
CREATE INDEX event_deliveries_event_idx ON event_deliveries (event_id);
13 changes: 11 additions & 2 deletions cloud/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ cargo run

Local mode uses a deterministic Ed25519 development seed and disables FCM. Neither setting is acceptable in production.

The service applies required schema migrations 0001-0004 and 0021-0023 before opening its listener, then starts the explicitly allowlisted performance-index migrations 0005-0020 in a bounded background task after `/health` is available. The online phase takes a non-blocking session lock, builds one index at a time with a generous per-index deadline, logs a failure, and retries on the next startup or guarded operator phase. A dirty SQLx migration row remains an operator error and is never cleared automatically. Retention cleanup runs once after bind and repeats every six hours while an instance is active.
The service applies required schema migrations 0001-0004 and 0021-0024 before opening its listener, then starts the explicitly allowlisted performance-index migrations 0005-0020 in a bounded background task after `/health` is available. The online phase takes a non-blocking session lock, builds one index at a time with a generous per-index deadline, logs a failure, and retries on the next startup or guarded operator phase. A dirty SQLx migration row remains an operator error and is never cleared automatically. Retention cleanup runs once after bind and repeats every six hours while an instance is active.

## HTTP Contract

Expand Down Expand Up @@ -55,6 +55,15 @@ All JSON uses camelCase. Every route except `GET /health` requires the `x-alera-
| `GET` | `/v1/mcp/runtimes` | Bearer MCP | Runtimes the MCP grant reaches |
| `POST` | `/v1/mcp/calls` | Bearer MCP | Resolve a runtime, audit the call, and sign a 120-second call grant |
| `POST` | `/v1/mcp/calls/{id}/outcome` | Bearer MCP | Record the call outcome once |
| `POST` | `/v1/runtime/domain-events` | Bearer runtime (`events:send` or `push:send`) | Store up to 100 idempotent domain events for webhooks and MCP Events |
| `GET` | `/v1/runtime/event-subscriptions` | Bearer runtime | Count active webhooks and MCP Events subscriptions for the runtime |
| `GET`, `POST` | `/v1/webhooks` | Bearer runtime | List or create the account's webhooks; creation returns the `whsec_` secret once |
| `DELETE` | `/v1/webhooks/{id}` | Bearer runtime | Delete a webhook |
| `POST` | `/v1/webhooks/{id}/test` | Bearer runtime | Queue a signed `alera.test` delivery |
| `POST` | `/v1/mcp/event-subscriptions` | Bearer MCP (edge only) | Verify a callback and store an MCP Events subscription bound to the grant |
| `POST` | `/v1/mcp/event-subscriptions/unsubscribe` | Bearer MCP (edge only) | Remove a subscription by identity; idempotent |
| `DELETE` | `/v1/mcp/event-subscriptions/{id}` | Bearer MCP (edge only) | Remove a subscription by id; idempotent |
| `POST` | `/v1/internal/event-deliveries/pump` | Edge only | Run one bounded fan-out and delivery drain (edge cron) |

The OAuth 2.1 authorization server for Remote MCP (`/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`, `/oauth/*`, and the `/device` page) follows [`../docs/remote-mcp.md`](../docs/remote-mcp.md). Its endpoints use the standard snake_case OAuth field names. `ALERA_MCP_ENABLED=false` removes the authorization server and gateway routes; device sign-in and grant management stay available.

Expand Down Expand Up @@ -90,7 +99,7 @@ The relay sees connection metadata, frame sizes, and timing only. The runtime an

Provider access tokens and authorization codes are used only during exchange and are never stored. PostgreSQL stores account emails and provider ids, hashed refresh tokens, runtime and device metadata, relay public keys, FCM tokens required for delivery, subscriptions, event payloads, and delivery outcomes. It does not store relay frames or private identity keys. Relay grants are short-lived JWTs and are not persisted.

Cleanup removes expired OAuth transactions and enrollment codes after one day, runtime events and delivery attempts after 30 days, hourly and burst quota rows after seven days, daily quota rows after 90 days, expired or revoked sessions after their retention window, and tombstones when they expire. Cloud Run with zero minimum instances performs this work after service activity, so an entirely inactive database may retain expired operational rows until the next startup.
Domain events and their webhook deliveries are kept for 24 hours; webhook and MCP Events signing secrets are encrypted with AES-256-GCM under `ALERA_WEBHOOK_SECRET_KEY` (see [`../docs/remote-mcp.md`](../docs/remote-mcp.md#events-and-webhooks)). Cleanup removes expired OAuth transactions and enrollment codes after one day, runtime events and delivery attempts after 30 days, hourly and burst quota rows after seven days, daily quota rows after 90 days, expired or revoked sessions after their retention window, and tombstones when they expire. Cloud Run with zero minimum instances performs this work after service activity, so an entirely inactive database may retain expired operational rows until the next startup.

Account deletion removes active account data transactionally. It keeps only HMAC-protected provider-identity tombstones for 90 days to prevent immediate quota or ban reset.

Expand Down
60 changes: 41 additions & 19 deletions cloud/src/api.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use axum::{
body::Body,
extract::{DefaultBodyLimit, Request, State},
http::{HeaderValue, StatusCode},
http::{HeaderMap, HeaderValue, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post, put},
Expand Down Expand Up @@ -66,6 +66,7 @@ pub fn router(state: AppState) -> Router {
.route("/v1/relay/grants", post(relay::create_grant))
.route("/v1/mobile/runtimes", get(relay::discover_runtimes))
.merge(crate::mcp_oauth::router(state.config.mcp.enabled))
.merge(crate::events::router())
.layer(DefaultBodyLimit::max(64 * 1024))
.layer(TraceLayer::new_for_http().make_span_with(request_span))
.layer(middleware::from_fn_with_state(
Expand Down Expand Up @@ -98,26 +99,34 @@ async fn require_edge_origin(
if request.uri().path() == "/health" || state.config.allow_direct_origin {
return Ok(next.run(request).await);
}
let provided = request
.headers()
.get("x-alera-origin-auth")
.and_then(header_text);
let valid = provided.is_some_and(|provided| {
token_matches(
provided,
state.config.edge_origin_token.as_deref(),
state.config.edge_previous_origin_token.as_deref(),
)
});
if !valid {
return Err(ApiError::unauthorized(
"invalid_origin",
"The request did not arrive through the Alera edge.",
));
}
require_origin_token(request.headers(), &state)?;
Ok(next.run(request).await)
}

/// Requires the edge origin token (current or previous) on `headers`, whatever
/// `ALLOW_DIRECT_ORIGIN` says. Internal routes call it themselves so they stay closed
/// when direct origin access is allowed; with no token configured they are unreachable.
pub(crate) fn require_origin_token(headers: &HeaderMap, state: &AppState) -> Result<(), ApiError> {
if origin_token_valid(
headers,
state.config.edge_origin_token.as_deref(),
state.config.edge_previous_origin_token.as_deref(),
) {
return Ok(());
}
Err(ApiError::unauthorized(
"invalid_origin",
"The request did not arrive through the Alera edge.",
))
}

fn origin_token_valid(headers: &HeaderMap, current: Option<&str>, previous: Option<&str>) -> bool {
headers
.get("x-alera-origin-auth")
.and_then(header_text)
.is_some_and(|provided| token_matches(provided, current, previous))
}

fn header_text(value: &HeaderValue) -> Option<&str> {
value.to_str().ok().filter(|value| !value.is_empty())
}
Expand All @@ -137,7 +146,9 @@ fn token_matches(provided: &str, current: Option<&str>, previous: Option<&str>)

#[cfg(test)]
mod tests {
use super::{request_span, token_matches};
use axum::http::{HeaderMap, HeaderValue};

use super::{origin_token_valid, request_span, token_matches};

#[derive(Clone, Default)]
struct Captured(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
Expand Down Expand Up @@ -189,4 +200,15 @@ mod tests {
assert!(token_matches("old", Some("new"), Some("old")));
assert!(!token_matches("other", Some("new"), Some("old")));
}

#[test]
fn origin_token_is_required_and_never_matches_when_unset() {
let mut headers = HeaderMap::new();
assert!(!origin_token_valid(&headers, Some("new"), None));
headers.insert("x-alera-origin-auth", HeaderValue::from_static("new"));
assert!(origin_token_valid(&headers, Some("new"), None));
assert!(!origin_token_valid(&headers, None, None));
headers.insert("x-alera-origin-auth", HeaderValue::from_static(""));
assert!(!origin_token_valid(&headers, Some(""), None));
}
}
1 change: 1 addition & 0 deletions cloud/src/api_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ impl ClientKind {
"configuration:read".to_owned(),
"configuration:write".to_owned(),
"enrollment:write".to_owned(),
"events:send".to_owned(),
"push:send".to_owned(),
"runtime:write".to_owned(),
"relay:identity".to_owned(),
Expand Down
Loading
Loading