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
4 changes: 4 additions & 0 deletions crates/webcodex-core/src/authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
//! names and policy values consumed by auth, route metadata, and tool contracts.

pub const SCOPE_RUNTIME_READ: &str = "runtime:read";
/// Consequential management of one exact caller-visible Runner process. This is
/// deliberately independent from Project, Job, Plugin, and administrator scopes.
pub const SCOPE_RUNNER_MANAGE: &str = "runner:manage";
pub const SCOPE_SESSION_COLLABORATE: &str = "session:collaborate";
pub const SCOPE_PROJECT_READ: &str = "project:read";
pub const SCOPE_PROJECT_WRITE: &str = "project:write";
Expand Down Expand Up @@ -108,6 +111,7 @@ pub const KNOWN_SCOPES: &[&str] = &[
SCOPE_COMPUTER_CLIPBOARD_READ,
SCOPE_COMPUTER_CLIPBOARD_WRITE,
SCOPE_RUNTIME_READ,
SCOPE_RUNNER_MANAGE,
SCOPE_SESSION_COLLABORATE,
SCOPE_PROJECT_READ,
SCOPE_PROJECT_WRITE,
Expand Down
237 changes: 237 additions & 0 deletions crates/webcodex-core/src/runner_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,32 @@ pub const RUNNER_CAPABILITY_NATIVE_TOOL_PLUGINS: &str = "native_tool_plugins";
/// Runner-local durable managed SSH resource registry. Missing on older Runners
/// is false and is never inferred from one-shot or persistent SSH execution.
pub const RUNNER_CAPABILITY_MANAGED_SSH_RESOURCES: &str = "managed_ssh_resources";
/// First-class read/check and fenced activation of the exact Runner process's
/// startup-bound configuration path. Missing on older Runners is false; Servers
/// must never fall back to PID/signal emulation for this operation.
pub const RUNNER_CAPABILITY_RUNNER_CONFIG_CONTROL: &str = "runner_config_control";
pub const RUNNER_CONFIG_REQUEST_KIND: &str = "runner_config";
pub const RUNNER_CONFIG_REQUEST_MAX_BYTES: usize = 512;
pub const RUNNER_CONFIG_RESPONSE_MAX_BYTES: usize = 4096;
pub const RUNNER_CONFIG_RESTART_REQUIRED_FIELDS: &[&str] = &[
"acp",
"capabilities",
"client_id",
"display_name",
"host_context",
"hostname",
"max_concurrent_jobs",
"mcp_gateway",
"owner",
"plugins",
"poll_interval_ms",
"project_registry_dir",
"quic",
"server_url",
"token",
"transport",
"websocket_connect_timeout_secs",
];
/// Capabilities guaranteed by every accepted protocol-generation-2 Runner.
/// These explicit bools remain wire facts shared by Server and Runner, but a
/// missing/false baseline bit rejects registration. Downstream consumers may
Expand Down Expand Up @@ -417,6 +443,7 @@ pub const RUNNER_CAPABILITY_NAMES: &[&str] = &[
RUNNER_CAPABILITY_CODING_AGENT_RUNS,
RUNNER_CAPABILITY_NATIVE_TOOL_PLUGINS,
RUNNER_CAPABILITY_MANAGED_SSH_RESOURCES,
RUNNER_CAPABILITY_RUNNER_CONFIG_CONTROL,
RUNNER_CAPABILITY_COMPUTER_CONTROL,
RUNNER_CAPABILITY_COMPUTER_SCROLL_TO_ELEMENT,
RUNNER_CAPABILITY_COMPUTER_KEY_INPUT,
Expand Down Expand Up @@ -675,6 +702,11 @@ pub struct RunnerCapabilities {
/// Runner-local managed SSH resource list/register/remove lifecycle.
#[serde(default, skip_serializing_if = "is_false")]
pub managed_ssh_resources: bool,
/// First-class bounded config check/reload implemented by this Runner
/// process. Missing on older Runners is false and is never inferred from OS,
/// transport, Plugin support, or protocol generation.
#[serde(default, skip_serializing_if = "is_false")]
pub runner_config_control: bool,
}

/// Bounded, non-secret status for the Runner's active configuration generation.
Expand Down Expand Up @@ -710,6 +742,143 @@ impl Default for RunnerConfigReloadStatus {
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunnerConfigAction {
Check,
Reload,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunnerConfigExecutionState {
NotStarted,
Completed,
OutcomeUnknown,
}

/// Closed Runner config operation. No filesystem path or raw configuration is
/// accepted: the target Runner always uses its startup-bound config path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunnerConfigOperationRequest {
pub action: RunnerConfigAction,
#[serde(default)]
pub expected_generation: Option<u64>,
}

impl RunnerConfigOperationRequest {
pub fn validate(&self) -> Result<(), &'static str> {
match (self.action, self.expected_generation) {
(RunnerConfigAction::Check, None) => Ok(()),
(RunnerConfigAction::Reload, Some(generation)) if generation > 0 => Ok(()),
(RunnerConfigAction::Check, Some(_)) => {
Err("check does not accept expected_generation")
}
(RunnerConfigAction::Reload, _) => {
Err("reload requires a positive expected_generation")
}
}
}
}

/// Bounded, non-secret result for one exact Runner config operation. Generation
/// is null only when Control cannot truthfully know the current generation after
/// a delivery failure or replacement; successful Runner responses always carry it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunnerConfigOperationResponse {
pub action: RunnerConfigAction,
pub execution_state: RunnerConfigExecutionState,
pub valid: Option<bool>,
pub current_generation: Option<u64>,
pub error_code: Option<String>,
pub error_field: Option<String>,
pub error_reason: Option<String>,
pub restart_required: bool,
pub restart_required_fields: Vec<String>,
}

impl RunnerConfigOperationResponse {
pub fn validate(&self) -> Result<(), &'static str> {
if self.current_generation == Some(0) {
return Err("current_generation must be positive when present");
}
if self.restart_required != !self.restart_required_fields.is_empty() {
return Err("restart_required does not match restart_required_fields");
}
let mut previous: Option<&str> = None;
for field in &self.restart_required_fields {
if !RUNNER_CONFIG_RESTART_REQUIRED_FIELDS.contains(&field.as_str()) {
return Err("unknown restart-required field");
}
if previous.is_some_and(|previous| previous >= field.as_str()) {
return Err("restart-required fields must be sorted and unique");
}
previous = Some(field);
}
match (self.error_field.as_deref(), self.error_reason.as_deref()) {
(None, None) => {}
(Some(field), Some("out_of_range"))
if matches!(
field,
"max_concurrent_jobs"
| "shell.max_persistent_shells"
| "shell.persistent_shell_idle_timeout_secs"
| "acp.max_concurrent_runs"
| "acp.permission_timeout_secs"
| "mcp.request_timeout_secs"
) => {}
_ => return Err("invalid config error diagnostic"),
}
if let Some(code) = self.error_code.as_deref() {
if !matches!(
code,
"invalid_request"
| "config_read_failed"
| "config_parse_failed"
| "config_validation_failed"
| "provider_config_invalid"
| "config_generation_conflict"
| "runner_unavailable"
| "runner_replaced"
| "capability_unavailable"
| "invalid_runner_response"
| "outcome_unknown"
) {
return Err("unknown Runner config error code");
}
}
match self.execution_state {
RunnerConfigExecutionState::Completed => {
if self.current_generation.is_none() || self.valid.is_none() {
return Err("completed config result requires validity and generation");
}
if self.valid == Some(true)
&& (self.error_code.is_some()
|| self.error_field.is_some()
|| self.error_reason.is_some())
{
return Err("valid config result cannot contain an error");
}
}
RunnerConfigExecutionState::NotStarted => {
if self.valid.is_some() || self.restart_required {
return Err(
"not-started config result cannot claim validation or restart fields",
);
}
}
RunnerConfigExecutionState::OutcomeUnknown => {
if self.valid.is_some() || self.restart_required {
return Err("unknown config outcome cannot claim validation or restart fields");
}
}
}
Ok(())
}
}

impl Default for RunnerCapabilities {
fn default() -> Self {
Self {
Expand Down Expand Up @@ -767,6 +936,7 @@ impl Default for RunnerCapabilities {
coding_agent_runs: false,
native_tool_plugins: false,
managed_ssh_resources: false,
runner_config_control: false,
}
}
}
Expand Down Expand Up @@ -3222,6 +3392,72 @@ where
mod envelope_tests {
use super::*;

#[test]
fn runner_config_operation_contract_is_closed_bounded_and_fail_closed_by_default() {
assert!(!RunnerCapabilities::default().runner_config_control);

let check = RunnerConfigOperationRequest {
action: RunnerConfigAction::Check,
expected_generation: None,
};
assert!(check.validate().is_ok());
assert!(RunnerConfigOperationRequest {
action: RunnerConfigAction::Check,
expected_generation: Some(1),
}
.validate()
.is_err());
assert!(RunnerConfigOperationRequest {
action: RunnerConfigAction::Reload,
expected_generation: None,
}
.validate()
.is_err());
assert!(RunnerConfigOperationRequest {
action: RunnerConfigAction::Reload,
expected_generation: Some(0),
}
.validate()
.is_err());
assert!(RunnerConfigOperationRequest {
action: RunnerConfigAction::Reload,
expected_generation: Some(1),
}
.validate()
.is_ok());

assert!(
serde_json::from_value::<RunnerConfigOperationRequest>(serde_json::json!({
"action": "check",
"path": "/tmp/not-authorized"
}))
.is_err()
);

let valid = RunnerConfigOperationResponse {
action: RunnerConfigAction::Check,
execution_state: RunnerConfigExecutionState::Completed,
valid: Some(true),
current_generation: Some(1),
error_code: None,
error_field: None,
error_reason: None,
restart_required: false,
restart_required_fields: Vec::new(),
};
assert!(valid.validate().is_ok());

let mut leaked_error = valid.clone();
leaked_error.error_code = Some("/private/path?token=secret".to_string());
leaked_error.valid = Some(false);
assert!(leaked_error.validate().is_err());

let mut unbounded_field = valid;
unbounded_field.restart_required = true;
unbounded_field.restart_required_fields = vec!["arbitrary.path".to_string()];
assert!(unbounded_field.validate().is_err());
}

fn sample_process_request() -> RunnerRequest {
RunnerRequest {
request_id: "req-process-1".to_string(),
Expand Down Expand Up @@ -3463,6 +3699,7 @@ mod envelope_tests {
coding_agent_runs: false,
native_tool_plugins: false,
managed_ssh_resources: false,
runner_config_control: false,
},
policy: None,
job_concurrency_limit: Some(4),
Expand Down
4 changes: 4 additions & 0 deletions crates/webcodex-runner-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,10 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result<String,
// Managed SSH resource lifecycle is likewise a running-binary
// capability and is never implied by generated static SSH config.
managed_ssh_resources: false,
// First-class config control is implemented by the running binary
// against its startup-bound path and must never be inferred from a
// generated static runner.toml capability block.
runner_config_control: false,
},
policy: GeneratedRunnerPolicy {
allow_raw_shell: true,
Expand Down
8 changes: 7 additions & 1 deletion crates/webcodex-runner-registry/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,15 @@ pub enum RunnerFeature {
CodingAgentRuns,
NativeToolPlugins,
ManagedSshResources,
RunnerConfigControl,
ComputerControl,
ComputerScrollToElement,
ComputerKeyInput,
ComputerWindowActivate,
ComputerTextInput,
}

const ALL_RUNNER_FEATURES: [RunnerFeature; 54] = [
const ALL_RUNNER_FEATURES: [RunnerFeature; 55] = [
RunnerFeature::Shell,
RunnerFeature::FileRead,
RunnerFeature::FileWrite,
Expand Down Expand Up @@ -114,6 +115,7 @@ const ALL_RUNNER_FEATURES: [RunnerFeature; 54] = [
RunnerFeature::CodingAgentRuns,
RunnerFeature::NativeToolPlugins,
RunnerFeature::ManagedSshResources,
RunnerFeature::RunnerConfigControl,
RunnerFeature::ComputerControl,
RunnerFeature::ComputerScrollToElement,
RunnerFeature::ComputerKeyInput,
Expand Down Expand Up @@ -199,6 +201,7 @@ impl RunnerFeature {
Self::CodingAgentRuns => wire::RUNNER_CAPABILITY_CODING_AGENT_RUNS,
Self::NativeToolPlugins => wire::RUNNER_CAPABILITY_NATIVE_TOOL_PLUGINS,
Self::ManagedSshResources => wire::RUNNER_CAPABILITY_MANAGED_SSH_RESOURCES,
Self::RunnerConfigControl => wire::RUNNER_CAPABILITY_RUNNER_CONFIG_CONTROL,
Self::ComputerControl => wire::RUNNER_CAPABILITY_COMPUTER_CONTROL,
Self::ComputerScrollToElement => wire::RUNNER_CAPABILITY_COMPUTER_SCROLL_TO_ELEMENT,
Self::ComputerKeyInput => wire::RUNNER_CAPABILITY_COMPUTER_KEY_INPUT,
Expand Down Expand Up @@ -268,6 +271,7 @@ impl RunnerFeature {
wire::RUNNER_CAPABILITY_CODING_AGENT_RUNS => Self::CodingAgentRuns,
wire::RUNNER_CAPABILITY_NATIVE_TOOL_PLUGINS => Self::NativeToolPlugins,
wire::RUNNER_CAPABILITY_MANAGED_SSH_RESOURCES => Self::ManagedSshResources,
wire::RUNNER_CAPABILITY_RUNNER_CONFIG_CONTROL => Self::RunnerConfigControl,
wire::RUNNER_CAPABILITY_COMPUTER_CONTROL => Self::ComputerControl,
wire::RUNNER_CAPABILITY_COMPUTER_SCROLL_TO_ELEMENT => Self::ComputerScrollToElement,
wire::RUNNER_CAPABILITY_COMPUTER_KEY_INPUT => Self::ComputerKeyInput,
Expand Down Expand Up @@ -328,6 +332,7 @@ impl RunnerFeature {
| Self::CodingAgentRuns
| Self::NativeToolPlugins
| Self::ManagedSshResources
| Self::RunnerConfigControl
| Self::ComputerControl
| Self::ComputerScrollToElement
| Self::ComputerKeyInput
Expand Down Expand Up @@ -393,6 +398,7 @@ impl RunnerFeature {
Self::CodingAgentRuns => capabilities.coding_agent_runs,
Self::NativeToolPlugins => capabilities.native_tool_plugins,
Self::ManagedSshResources => capabilities.managed_ssh_resources,
Self::RunnerConfigControl => capabilities.runner_config_control,
Self::ComputerControl => capabilities.computer_control,
Self::ComputerScrollToElement => capabilities.computer_scroll_to_element,
Self::ComputerKeyInput => capabilities.computer_key_input,
Expand Down
Loading
Loading