Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 43 additions & 7 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,50 @@ platform = 'cfg(windows)'
filter = 'package(labby-codemode) & test(git::provider::tests::)'
threads-required = 'num-test-threads'

# Shared lifecycle harness tests are compiled into several integration binaries.
# Windows CI observed four concurrent instances exhausting their unchanged 45s
# readiness deadline during database initialization; later instances finished in
# 2-7s. Reserve slots for these cases to avoid competing daemon startups. Their
# own multi-process scenarios still run concurrently inside the test.
# Shared lifecycle harness tests are compiled into many integration binaries.
# They launch real daemons, process groups, listeners, and cleanup guardians.
# Windows first exposed concurrent copies exhausting readiness budgets; hosted
# Linux shards and macOS reproduction showed the same interference class.
# Reserve every nextest slot for each live_labby case on every platform while
# preserving each case's intended internal multi-process concurrency.
[[profile.ci.overrides]]
platform = 'cfg(windows)'
filter = 'package(labby) & test(live_labby::tests::)'
filter = 'package(labby) & test(live_labby::)'
threads-required = 'num-test-threads'

# Integration binaries that launch real Labby daemons, browser/process fixtures,
# or the shared lifecycle harness must not overlap on one runner. Their product
# deadlines are intentional oracles; runner contention must not consume them.
# Reserve every nextest slot for each test in these binaries instead of inflating
# deadlines. The target shards still execute on separate CI runners in parallel.
[[profile.ci.overrides]]
filter = '''package(labby) & (
binary(=browser_runtime_e2e)
| binary(=codemode_qualification)
| binary(=e2e_coverage_report)
| binary(=gateway_auto_reconnect)
| binary(=lifecycle_conformance)
| binary(=live_api_actions)
| binary(=live_browser_bridge)
| binary(=live_cli_actions)
| binary(=live_http_ipv6)
| binary(=live_http_observability)
| binary(=live_http_routes)
| binary(=live_integration_identity)
| binary(=live_mcp_actions)
| binary(=live_process_harness)
| binary(=live_protected_routes)
| binary(=live_restart_persistence)
| binary(=live_surface_parity)
| binary(=mcp_apps_host_qualification)
| binary(=mcp_primitives_qualification)
| binary(=mcp_spec_http_contracts)
| binary(=mcp_spec_wire_compliance)
| binary(=mcp_tools_transport_qualification)
| binary(=oauth_qualification)
| binary(=proxy_qualification)
| binary(=skills_mcp_e2e)
| binary(=webmcp_browser_qualification)
)'''
threads-required = 'num-test-threads'

# These three process-inventory controls hit their unchanged 3s deadlines when
Expand Down
21 changes: 20 additions & 1 deletion crates/labby-runtime/src/agent_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub struct AgentResourceBounds {
impl AgentResourceBounds {
pub fn validate(self) -> Result<Self, AgentRuntimeError> {
if self.max_runtime_millis == 0
|| self.max_runtime_millis > 86_400_000
|| self.max_runtime_millis > AGENT_MAX_RUNTIME_MILLIS
|| self.max_output_bytes == 0
|| self.max_output_bytes > 64 * 1024 * 1024
|| self.max_external_effects > 10_000
Expand Down Expand Up @@ -619,6 +619,25 @@ mod tests {
);
}

#[test]
fn resource_bounds_cannot_outlive_the_authority_lease_contract() {
let valid = AgentResourceBounds {
max_runtime_millis: AGENT_MAX_RUNTIME_MILLIS,
max_output_bytes: 1,
max_external_effects: 0,
};
assert_eq!(valid.validate().unwrap(), valid);

let overlong = AgentResourceBounds {
max_runtime_millis: AGENT_MAX_RUNTIME_MILLIS + 1,
..valid
};
assert_eq!(
overlong.validate().unwrap_err(),
AgentRuntimeError::InvalidBounds
);
}

#[tokio::test]
async fn runtime_bound_cancels_an_overrunning_executor_and_requests_cleanup() {
let initial = epochs(1);
Expand Down
1 change: 1 addition & 0 deletions crates/labby-runtime/src/gateway_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ fn default_mcp_scopes() -> Vec<String> {
pub struct McpAppsConfig {
/// Attach MCP App metadata to the always-available `mcp_app` control tool and advertise its UI resources.
/// The control tool itself remains available when this is false.
/// Fresh installs expose the manager UI by default.
#[serde(default = "default_true")]
pub manager: bool,
/// Advertise the synthetic Add Server app tool and its UI resources.
Expand Down
5 changes: 3 additions & 2 deletions crates/labby/src/mcp/handlers_resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1584,8 +1584,9 @@ impl LabMcpServer {
// Branch 0: MCP Apps UI resources. This must precede all lab://
// fallbacks so ui:// has its own exact lookup semantics.
//
// The `mcp_app` control tool is always locally available, but its own
// Labby-owned UI is opt-in like every other Labby-owned MCP App.
// The `mcp_app` control tool is always locally available. Its UI follows
// `mcp_apps.manager`: fresh installs enable it, while an operator can
// disable the resource surface without removing the control tool.
#[cfg(feature = "gateway")]
if uri.starts_with(MCP_APPS_APP_URI) {
if !self.mcp_apps_config().await.manager {
Expand Down
18 changes: 11 additions & 7 deletions crates/labby/tests/action_matrix_completeness.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
#![allow(clippy::panic)]

#[path = "support/lib.rs"]
mod support;
#[allow(dead_code)]
#[path = "support/action_matrix.rs"]
mod action_matrix;
#[allow(dead_code)]
#[path = "support/authority_matrix.rs"]
mod authority_matrix;

use std::collections::{BTreeMap, BTreeSet};

use labby_primitives::access::{Capability, CapabilitySchemaVersion, RoleTemplate};
use serde_json::Value;
use support::action_matrix::{
use action_matrix::{
CatalogAction, EXPECTED_ACTIONS, EXPECTED_API_ACTIONS, EXPECTED_CLI_ACTIONS,
EXPECTED_MCP_ACTIONS, EXPECTED_SHARED_CLI_MCP_API_ACTIONS, EXPECTED_WEB_ACTIONS, EvidenceLevel,
PersistenceClass, ScenarioKind, ScenarioOwner, Surface, catalog_map, intent_map,
intent_map_from, intents, validate_intent_shape,
};
use support::authority_matrix::{
use authority_matrix::{
DEPOT_OPERATIONS, OperationClass, OwnerKind, ResourceFamily, classify_labby,
depot_fixture_operation_names, depot_snapshot_operation_names, duplicate_depot_operations,
};
use labby_primitives::access::{Capability, CapabilitySchemaVersion, RoleTemplate};
use serde_json::Value;

const ACTION_CATALOG: &str = include_str!("../../../docs/generated/action-catalog.json");
const AUTHORITY_MATRIX: &str =
Expand Down Expand Up @@ -994,7 +998,7 @@ services = ["stash"]

#[test]
fn generated_outcomes_cannot_downgrade_required_intent() {
use support::action_matrix::{CaseOutcome, OutcomeStatus, outcome_satisfies};
use action_matrix::{CaseOutcome, OutcomeStatus, outcome_satisfies};
let intent = intents().iter().find(|intent| intent.required).unwrap();
let skipped = CaseOutcome {
key: intent.key(),
Expand Down
78 changes: 78 additions & 0 deletions crates/labby/tests/ci_changed_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,15 @@ fn rust_manifests_lockfiles_and_toolchains_run_full_tests() {
}
}

#[test]
fn nextest_policy_changes_run_the_full_rust_test_path() {
let out = classify("pull_request", &[".config/nextest.toml"]);
assert_eq!(out["rust_compile"], "true");
assert_eq!(out["rust_test"], "true");
assert_eq!(out["docker"], "true");
assert_eq!(out["release"], "true");
}

#[test]
fn frontend_changes_enable_web_release_and_container_without_rust_tests() {
let out = classify("pull_request", &["apps/gateway-admin/app/page.tsx"]);
Expand Down Expand Up @@ -1210,6 +1219,74 @@ fn ci_gate_aggregates_every_non_advisory_job() {
}
}

/// Process-owning integration fixtures are compiled into many test binaries.
/// The CI profile must serialize both duplicated harness-unit cases and every
/// integration binary that launches real process state.
#[test]
fn nextest_ci_isolates_process_harnesses_and_timing_oracles() {
let root = repo_root();
let nextest =
fs::read_to_string(root.join(".config/nextest.toml")).expect("read .config/nextest.toml");
let lifecycle_filter = "filter = 'package(labby) & test(live_labby::)'";
assert!(
nextest.contains(lifecycle_filter),
"CI must serialize every embedded live_labby harness copy"
);
let lifecycle = nextest
.split(lifecycle_filter)
.nth(1)
.and_then(|section| section.split("[[").next())
.expect("live_labby override");
assert!(
lifecycle.contains("threads-required = 'num-test-threads'"),
"live_labby override must reserve every nextest slot"
);
assert!(
!lifecycle.contains("platform ="),
"process-harness isolation must apply on Linux, macOS, and Windows"
);

let process_override = nextest
.split("filter = '''package(labby) & (")
.nth(1)
.and_then(|section| section.split("[[").next())
.expect("process-owning integration override");
assert!(
process_override.contains("threads-required = 'num-test-threads'"),
"process-owning integration tests must reserve every nextest slot"
);
let test_dir = root.join("crates/labby/tests");
let mut process_binaries = Vec::new();
for entry in fs::read_dir(&test_dir).expect("read integration test directory") {
let path = entry.expect("test entry").path();
if path.extension().and_then(|value| value.to_str()) != Some("rs") {
continue;
}
let source = fs::read_to_string(&path).expect("read integration test source");
let path_marker = ["support", "/live_labby.rs"].concat();
let type_marker = ["support::", "LiveLabby"].concat();
let module_marker = ["support::", "live_labby"].concat();
if source.contains(&path_marker)
|| source.contains(&type_marker)
|| source.contains(&module_marker)
{
process_binaries.push(
path.file_stem()
.and_then(|value| value.to_str())
.expect("UTF-8 test name")
.to_owned(),
);
}
}
assert!(!process_binaries.is_empty());
for binary in process_binaries {
assert!(
process_override.contains(&format!("binary(={binary})")),
"process-owning integration binary {binary} must be isolated in nextest CI"
);
}
}

/// The merge gate must finish in about ten minutes. That budget is kept by
/// fanning the long serial suites out across matrix shards, by moving the
/// slow non-gating suites (coverage, the gateway-slice re-run, doctests) off
Expand Down Expand Up @@ -1261,6 +1338,7 @@ fn merge_gate_shards_heavy_suites_to_stay_under_ten_minutes() {
"--partition \"hash:${index}/${unit_shards}\"",
"for file in crates/labby/tests/*.rs",
"cargo nextest run -p labby",
"not test(live_labby::) | binary(=live_process_harness)",
"--exclude labby",
"cargo test --doc --workspace --all-features --locked",
] {
Expand Down
28 changes: 22 additions & 6 deletions crates/labby/tests/mcp_apps_host_qualification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,23 @@ async fn q4_real_resources_render_in_distinct_openai_and_anthropic_emulators() {
.await
.expect("real Labby MCP process");

let initially_hidden = runner.read_resource("ui://lab/apps/manage").await;
// Fresh installs intentionally expose Labby-owned MCP Apps by default.
// Exercise the policy transition explicitly so this live oracle verifies
// that direct resources/read cannot bypass a disabled surface.
let initially_disabled = runner
.call_raw(
"mcp_app",
serde_json::json!({"action":"disable", "params":{"target":"manager"}}),
)
.await
.expect("disable manager through real tools/call");
assert_ne!(
initially_disabled.is_error,
Some(true),
"initial disable failed: {initially_disabled:?}"
);
assert!(
initially_hidden.is_err(),
runner.read_resource("ui://lab/apps/manage").await.is_err(),
"disabled app resource must not bypass policy"
);

Expand Down Expand Up @@ -263,10 +277,12 @@ async fn q4_real_resources_render_in_distinct_openai_and_anthropic_emulators() {
Some(true),
"disable failed: {disabled:?}"
);
assert!(
runner.read_resource(mcp_uri).await.is_err(),
"revoked resource remained readable on the same authenticated session"
);
for uri in [mcp_uri, openai_uri] {
assert!(
runner.read_resource(uri).await.is_err(),
"revoked resource {uri} remained readable on the same authenticated session"
);
}

let cleanup = runner.finish().await;
assert!(
Expand Down
39 changes: 26 additions & 13 deletions crates/labby/tests/support/mcp_apps_host_qualification/runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -121,21 +121,38 @@ function listen() {
server.listen(0, "127.0.0.1", () => ok(server.address().port));
});
}
async function wait(p, s) {
async function waitManagerSwitch(p, checked) {
const expected = String(checked);
try {
await p
.locator("#status")
.filter({ hasText: s })
.locator(
`button[data-target=manager][aria-checked="${expected}"]:not([disabled])`,
)
.waitFor({ timeout: 5000 });
} catch (e) {
const button = p.locator("button[data-target=manager]");
throw new Error(
`${e.message}; actual status=${await p
`${e.message}; actual checked=${await button
.getAttribute("aria-checked")
.catch(() => "missing")}; summary=${await p
.locator("#summary")
.textContent()
.catch(() => "missing")}; status=${await p
.locator("#status")
.textContent()
.catch(() => "missing")}`,
);
}
}
async function setManager(enabled) {
await call({
name: "mcp_app",
arguments: {
action: enabled ? "enable" : "disable",
params: { target: "manager" },
},
});
}
async function csp(p, port) {
const got = await p.evaluate(async (port) => {
const violations = [];
Expand Down Expand Up @@ -176,16 +193,10 @@ async function csp(p, port) {
return got;
}
async function exercise(host, p, port) {
await wait(p, "up to date");
const policy = await csp(p, port),
on = await p
.locator("button[data-target=manager]")
.getAttribute("aria-checked");
await waitManagerSwitch(p, true);
const policy = await csp(p, port);
await p.locator("button[data-target=manager]").click();
await wait(
p,
on === "true" ? "Disabled MCP Apps Manager" : "Enabled MCP Apps Manager",
);
await waitManagerSwitch(p, false);
const auth = await p.evaluate(() =>
invalidMcpCall({
name: "mcp_app",
Expand Down Expand Up @@ -214,6 +225,7 @@ try {
await ctx.exposeFunction("invalidMcpCall", (x) =>
call(x, "invalid-q4-token"),
);
await setManager(true);
const o = await ctx.newPage();
await o.addInitScript(
() =>
Expand Down Expand Up @@ -242,6 +254,7 @@ try {
message: "injected OpenAI transport failure",
});
events.find((event) => event.host === "openai-emulator").type_error_calls = 1;
await setManager(true);
const h = await ctx.newPage();
await h.goto(`http://127.0.0.1:${port}/anthropic`);
await h.waitForTimeout(250);
Expand Down
9 changes: 9 additions & 0 deletions docs/dev/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ Preferred runner:
- use `cargo test` only when nextest is unavailable or you need a narrow one-off command that nextest does not cover cleanly
- for this repo, `cargo nextest run --manifest-path crates/labby/Cargo.toml --all-features` is the standard full-crate verification command

CI splits `crates/labby/tests/*.rs` across `labby-int-*` target shards. Many of
those binaries embed the same `live_labby` process-supervision harness. Shards
de-duplicate those internal support tests and execute them once through the
`live_process_harness` target. The CI nextest profile also reserves all local
test slots for process-owning integration binaries, so separate daemon/browser
fixtures cannot consume one another's readiness, cleanup, or timeout budgets.
This preserves the product deadlines the tests prove without trading flakiness
for inflated timeouts.

If tests were not run, say so explicitly.

## Command Guidance
Expand Down
Loading
Loading