fix(grid-agent): move setup into YAML preferences (#135)
This commit is contained in:
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -2282,6 +2282,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml_ng",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -3405,6 +3406,12 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -3559,6 +3566,19 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml_ng"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -4290,6 +4310,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"integrated": false,
|
|
||||||
"split": false,
|
|
||||||
"llm": {
|
|
||||||
"endpoint_url": "https://llm.example.invalid/v1/chat/completions",
|
|
||||||
"api_key": "<set-via-environment-or-restricted-secret-file>"
|
|
||||||
},
|
|
||||||
"authorized_avatar_uuids": [],
|
|
||||||
"timeouts": {
|
|
||||||
"startup_seconds": 30,
|
|
||||||
"shutdown_seconds": 10,
|
|
||||||
"request_seconds": 60
|
|
||||||
},
|
|
||||||
"limits": {
|
|
||||||
"grid_event_queue": 256,
|
|
||||||
"control_queue": 32,
|
|
||||||
"observable_queue": 512,
|
|
||||||
"max_body_bytes": 1048576,
|
|
||||||
"max_message_bytes": 16384,
|
|
||||||
"max_conversation_messages": 64,
|
|
||||||
"max_tool_calls": 16,
|
|
||||||
"max_authorized_avatars": 128,
|
|
||||||
"max_background_tasks": 2
|
|
||||||
},
|
|
||||||
"storage_path": "data/grid-agent",
|
|
||||||
"behavior": {
|
|
||||||
"heartbeat_seconds": 30,
|
|
||||||
"settle_milliseconds": 2000,
|
|
||||||
"response_delay_min_milliseconds": 350,
|
|
||||||
"response_delay_max_milliseconds": 1200,
|
|
||||||
"attention_dwell_milliseconds": 4000,
|
|
||||||
"idle_interval_seconds": 120,
|
|
||||||
"action_timeout_seconds": 15,
|
|
||||||
"max_walk_duration_seconds": 10,
|
|
||||||
"stuck_timeout_seconds": 3,
|
|
||||||
"min_action_interval_milliseconds": 750,
|
|
||||||
"max_walk_distance_meters": 8,
|
|
||||||
"max_attention_distance_meters": 96,
|
|
||||||
"idle_look_enabled": true
|
|
||||||
},
|
|
||||||
"reconnect": {
|
|
||||||
"initial_delay_milliseconds": 1000,
|
|
||||||
"maximum_delay_seconds": 60,
|
|
||||||
"stable_reset_seconds": 120,
|
|
||||||
"jitter_basis_points": 2000,
|
|
||||||
"offline_work_capacity": 128
|
|
||||||
},
|
|
||||||
"conversation": {
|
|
||||||
"persistence_enabled": false,
|
|
||||||
"max_active_sessions": 512,
|
|
||||||
"max_turns_per_session": 64,
|
|
||||||
"max_session_bytes": 262144,
|
|
||||||
"max_total_bytes": 8388608,
|
|
||||||
"max_tool_results_per_session": 16,
|
|
||||||
"max_tool_result_bytes": 16384,
|
|
||||||
"max_persisted_bytes": 16777216,
|
|
||||||
"max_summary_bytes": 8192
|
|
||||||
},
|
|
||||||
"interaction": {
|
|
||||||
"aliases": ["metacrate"],
|
|
||||||
"debounce_milliseconds": 250,
|
|
||||||
"model_timeout_seconds": 30,
|
|
||||||
"public_rate_milliseconds": 750,
|
|
||||||
"im_rate_milliseconds": 250,
|
|
||||||
"public_followup_seconds": 120,
|
|
||||||
"max_response_bytes": 2048,
|
|
||||||
"grid_chunk_bytes": 1023,
|
|
||||||
"max_active_senders": 512,
|
|
||||||
"max_queued_per_sender": 16,
|
|
||||||
"max_concurrent_inference": 4,
|
|
||||||
"max_duplicate_ids": 4096,
|
|
||||||
"max_debounce_fragments": 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
config/grid-agent.example.yml
Normal file
52
config/grid-agent.example.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
schema_version: 1
|
||||||
|
integrated: false
|
||||||
|
split: false
|
||||||
|
llm:
|
||||||
|
endpoint_url: https://llm.example.invalid/v1/chat/completions
|
||||||
|
api_key: <replace-in-private-config>
|
||||||
|
model: <endpoint-model-name>
|
||||||
|
authorized_avatar_uuids: []
|
||||||
|
storage_path: data/grid-agent
|
||||||
|
timeouts:
|
||||||
|
startup_seconds: 30
|
||||||
|
shutdown_seconds: 10
|
||||||
|
request_seconds: 60
|
||||||
|
limits:
|
||||||
|
grid_event_queue: 256
|
||||||
|
control_queue: 32
|
||||||
|
observable_queue: 512
|
||||||
|
max_body_bytes: 1048576
|
||||||
|
max_message_bytes: 16384
|
||||||
|
max_conversation_messages: 64
|
||||||
|
max_tool_calls: 16
|
||||||
|
max_authorized_avatars: 128
|
||||||
|
max_background_tasks: 2
|
||||||
|
behavior:
|
||||||
|
heartbeat_seconds: 30
|
||||||
|
settle_milliseconds: 2000
|
||||||
|
response_delay_min_milliseconds: 350
|
||||||
|
response_delay_max_milliseconds: 1200
|
||||||
|
attention_dwell_milliseconds: 4000
|
||||||
|
idle_interval_seconds: 120
|
||||||
|
action_timeout_seconds: 15
|
||||||
|
max_walk_duration_seconds: 10
|
||||||
|
stuck_timeout_seconds: 3
|
||||||
|
min_action_interval_milliseconds: 750
|
||||||
|
max_walk_distance_meters: 8
|
||||||
|
max_attention_distance_meters: 96
|
||||||
|
idle_look_enabled: true
|
||||||
|
reconnect:
|
||||||
|
initial_delay_milliseconds: 1000
|
||||||
|
maximum_delay_seconds: 60
|
||||||
|
stable_reset_seconds: 120
|
||||||
|
jitter_basis_points: 2000
|
||||||
|
offline_work_capacity: 128
|
||||||
|
conversation:
|
||||||
|
persistence_enabled: false
|
||||||
|
interaction:
|
||||||
|
aliases: [metacrate]
|
||||||
|
debounce_milliseconds: 250
|
||||||
|
model_timeout_seconds: 30
|
||||||
|
public_rate_milliseconds: 750
|
||||||
|
im_rate_milliseconds: 250
|
||||||
|
public_followup_seconds: 120
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"integrated": true,
|
|
||||||
"split": false,
|
|
||||||
"llm": {
|
|
||||||
"endpoint_url": "https://llm.example.invalid/v1/chat/completions"
|
|
||||||
},
|
|
||||||
"grid": {
|
|
||||||
"login_url": "https://grid.example.invalid/login",
|
|
||||||
"avatar_name": "Service Avatar"
|
|
||||||
},
|
|
||||||
"secret_files": {
|
|
||||||
"llm_api_key": "secrets/llm-api-key",
|
|
||||||
"grid_password": "secrets/grid-password"
|
|
||||||
},
|
|
||||||
"authorized_avatar_uuids": [
|
|
||||||
"00000000-0000-4000-8000-000000000001"
|
|
||||||
],
|
|
||||||
"storage_path": "data/grid-agent"
|
|
||||||
}
|
|
||||||
15
config/grid-agent.integrated.example.yml
Normal file
15
config/grid-agent.integrated.example.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
schema_version: 1
|
||||||
|
integrated: true
|
||||||
|
split: false
|
||||||
|
llm:
|
||||||
|
endpoint_url: https://llm.example.invalid/v1/chat/completions
|
||||||
|
api_key: <replace-in-private-config>
|
||||||
|
model: <endpoint-model-name>
|
||||||
|
grid:
|
||||||
|
login_url: https://grid.example.invalid/login
|
||||||
|
avatar_name: Service Avatar
|
||||||
|
password: <replace-in-private-config>
|
||||||
|
# These UUIDs are the only users allowed to request privileged mutations.
|
||||||
|
authorized_avatar_uuids:
|
||||||
|
- 00000000-0000-4000-8000-000000000001
|
||||||
|
storage_path: data/grid-agent
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"integrated": false,
|
|
||||||
"split": true,
|
|
||||||
"llm": {
|
|
||||||
"endpoint_url": "https://llm.example.invalid/v1/chat/completions"
|
|
||||||
},
|
|
||||||
"grid": {
|
|
||||||
"login_url": "https://grid.example.invalid/login",
|
|
||||||
"avatar_name": "Service Avatar"
|
|
||||||
},
|
|
||||||
"secret_files": {
|
|
||||||
"llm_api_key": "secrets/llm-api-key",
|
|
||||||
"grid_password": "secrets/grid-password",
|
|
||||||
"control_operator_token": "secrets/control-operator-token",
|
|
||||||
"control_observer_token": "secrets/control-observer-token"
|
|
||||||
},
|
|
||||||
"authorized_avatar_uuids": [
|
|
||||||
"00000000-0000-4000-8000-000000000001"
|
|
||||||
],
|
|
||||||
"storage_path": "data/grid-agent",
|
|
||||||
"control": {
|
|
||||||
"listen": "127.0.0.1:9764"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
config/grid-agent.split.example.yml
Normal file
18
config/grid-agent.split.example.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
schema_version: 1
|
||||||
|
integrated: false
|
||||||
|
split: true
|
||||||
|
llm:
|
||||||
|
endpoint_url: https://llm.example.invalid/v1/chat/completions
|
||||||
|
api_key: <replace-in-private-config>
|
||||||
|
model: <endpoint-model-name>
|
||||||
|
grid:
|
||||||
|
login_url: https://grid.example.invalid/login
|
||||||
|
avatar_name: Service Avatar
|
||||||
|
password: <replace-in-private-config>
|
||||||
|
authorized_avatar_uuids:
|
||||||
|
- 00000000-0000-4000-8000-000000000001
|
||||||
|
storage_path: data/grid-agent
|
||||||
|
control:
|
||||||
|
listen: 127.0.0.1:9764
|
||||||
|
operator_token: <replace-in-private-config>
|
||||||
|
observer_token: <replace-in-private-config-with-distinct-value>
|
||||||
@@ -21,6 +21,7 @@ reqwest = { version = "0.13.4", default-features = false, features = ["rustls"]
|
|||||||
rustls = { version = "0.23.43", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
|
rustls = { version = "0.23.43", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_yaml_ng = "0.10.0"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
|
tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
|
||||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] }
|
tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] }
|
||||||
|
|||||||
@@ -11,24 +11,20 @@ adapters. The `live-grid` feature exposes the owner for the existing
|
|||||||
client's native `NetworkManager` for login, event-queue readiness, disconnect
|
client's native `NetworkManager` for login, event-queue readiness, disconnect
|
||||||
notifications, and logout instead of adding a protocol client.
|
notifications, and logout instead of adding a protocol client.
|
||||||
|
|
||||||
The LLM connection identity has exactly two resolved fields:
|
The LLM connection identity has an exact `llm.endpoint_url`, `llm.api_key`, and
|
||||||
`llm.endpoint_url` and `llm.api_key`. The endpoint is used exactly as supplied;
|
optional `llm.model`. The endpoint is used exactly as supplied;
|
||||||
there are no providers, presets, base-URL rewrites, model catalogs, discovery,
|
there are no providers, presets, base-URL rewrites, model catalogs, discovery,
|
||||||
or provider SDKs. `Debug`/`Display` output removes API keys, grid passwords, URL
|
or provider SDKs. `Debug`/`Display` output removes API keys, grid passwords, URL
|
||||||
user information, and URL query values. Secret wrappers are not serializable.
|
user information, and URL query values. Secret wrappers are not serializable.
|
||||||
|
|
||||||
Configuration precedence, from lowest to highest, is built-in defaults, an
|
Persistent setup lives in the platform `config.yml` (normally
|
||||||
optional JSON file, its referenced secret files, then environment values (an
|
`~/.config/metacrate/config.yml` on Linux). The file contains grid/AI
|
||||||
environment-referenced secret file is below a direct environment secret).
|
connectivity and the exact privileged avatar UUID list; it is written with mode
|
||||||
Supported secret environment variables are
|
0600 on Unix. `--preferences` opens the setup panel without starting the
|
||||||
`METACRATE_AGENT_LLM_API_KEY[_FILE]` and
|
service. `--import-env .env` performs a one-time import of the legacy
|
||||||
`METACRATE_AGENT_GRID_PASSWORD[_FILE]`. Split control uses the separate
|
`GRID_*`/`OPENAPI_*` values; runtime configuration does not read `.env` or
|
||||||
`METACRATE_AGENT_CONTROL_OPERATOR_TOKEN[_FILE]` and optional
|
connection environment variables. JSON files and referenced secret files are
|
||||||
`METACRATE_AGENT_CONTROL_OBSERVER_TOKEN[_FILE]`, plus
|
accepted only as a legacy migration input.
|
||||||
`METACRATE_AGENT_CONTROL_LISTEN`. Secret files must be bounded regular,
|
|
||||||
non-symlink UTF-8 files containing one line. Operators must restrict their OS
|
|
||||||
ACLs to the service identity; the core uses only portable `std::fs` checks and
|
|
||||||
does not assume Unix permission bits.
|
|
||||||
|
|
||||||
Run the focused offline gate with:
|
Run the focused offline gate with:
|
||||||
|
|
||||||
@@ -36,9 +32,9 @@ Run the focused offline gate with:
|
|||||||
cargo test --locked -p metacrate-grid-agent
|
cargo test --locked -p metacrate-grid-agent
|
||||||
cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings
|
cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings
|
||||||
cargo run --locked -p metacrate-grid-agent -- \
|
cargo run --locked -p metacrate-grid-agent -- \
|
||||||
--config config/grid-agent.example.json --check-config
|
--config config/grid-agent.example.yml --check-config
|
||||||
cargo run --locked -p metacrate-grid-agent -- \
|
cargo run --locked -p metacrate-grid-agent -- \
|
||||||
--config config/grid-agent.example.json --run-once
|
--config config/grid-agent.example.yml --run-once
|
||||||
```
|
```
|
||||||
|
|
||||||
See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md)
|
See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md)
|
||||||
@@ -68,6 +64,6 @@ The complete quick start, platform paths, systemd/Windows service operation,
|
|||||||
secret rotation, backup/upgrade/rollback, failure playbooks, resource defaults,
|
secret rotation, backup/upgrade/rollback, failure playbooks, resource defaults,
|
||||||
and unsupported-operation list are in
|
and unsupported-operation list are in
|
||||||
[`../../docs/grid-agent-operations.md`](../../docs/grid-agent-operations.md).
|
[`../../docs/grid-agent-operations.md`](../../docs/grid-agent-operations.md).
|
||||||
Milestone acceptance, resource budgets, evidence, and opt-in live validation
|
Milestone acceptance, resource budgets, evidence, and live-grid validation
|
||||||
are defined in
|
are defined in
|
||||||
[`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md).
|
[`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Reproducible milestone acceptance evidence and fail-closed live opt-ins.
|
//! Reproducible milestone acceptance evidence and live-grid verification.
|
||||||
|
|
||||||
#![allow(clippy::missing_errors_doc)]
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
|
||||||
@@ -237,44 +237,6 @@ impl AcceptanceEvidenceWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
|
||||||
pub struct LiveGridOptIns {
|
|
||||||
pub login: bool,
|
|
||||||
pub chat_and_im: bool,
|
|
||||||
pub script_delivery: bool,
|
|
||||||
pub landmarks_and_roaming: bool,
|
|
||||||
pub reversible_build: bool,
|
|
||||||
pub visual_capture: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LiveGridOptIns {
|
|
||||||
#[must_use]
|
|
||||||
pub fn from_environment(get: impl Fn(&str) -> Option<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
login: confirmed(&get, "METACRATE_AGENT_LIVE_LOGIN", "LOGIN"),
|
|
||||||
chat_and_im: confirmed(&get, "METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM"),
|
|
||||||
script_delivery: confirmed(&get, "METACRATE_AGENT_LIVE_SCRIPT", "SCRIPT"),
|
|
||||||
landmarks_and_roaming: confirmed(&get, "METACRATE_AGENT_LIVE_LANDMARKS", "LANDMARKS"),
|
|
||||||
reversible_build: confirmed(&get, "METACRATE_AGENT_LIVE_BUILD", "BUILD-CLEANUP"),
|
|
||||||
visual_capture: confirmed(&get, "METACRATE_AGENT_LIVE_VISUAL", "VISUAL"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate(self) -> Result<Self, AcceptanceError> {
|
|
||||||
if !self.login && self != Self::default() {
|
|
||||||
return Err(AcceptanceError::Gate(
|
|
||||||
"live action opt-ins require the separate login opt-in",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn confirmed(get: &impl Fn(&str) -> Option<String>, name: &str, literal: &str) -> bool {
|
|
||||||
get(name).is_some_and(|value| value == literal)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AcceptanceResponder;
|
struct AcceptanceResponder;
|
||||||
|
|
||||||
impl InteractionResponder for AcceptanceResponder {
|
impl InteractionResponder for AcceptanceResponder {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ pub trait WorldMutator: AuthorizedToolBackend {}
|
|||||||
/// Inert deterministic backend used by the foundational offline service.
|
/// Inert deterministic backend used by the foundational offline service.
|
||||||
///
|
///
|
||||||
/// It performs no login or network operation and is available without the
|
/// It performs no login or network operation and is available without the
|
||||||
/// opt-in live-grid dependency graph.
|
/// live-grid dependency graph.
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
pub struct OfflineGridBackend;
|
pub struct OfflineGridBackend;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
use crate::types::{MAX_BODY_BYTES, MAX_CONVERSATION_MESSAGES, MAX_MESSAGE_BYTES, MAX_TOOL_CALLS};
|
use crate::types::{MAX_BODY_BYTES, MAX_CONVERSATION_MESSAGES, MAX_MESSAGE_BYTES, MAX_TOOL_CALLS};
|
||||||
use libremetaverse_types::UUID;
|
use libremetaverse_types::UUID;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Write as _;
|
||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -21,22 +22,10 @@ const MAX_QUEUE_CAPACITY: usize = 8_192;
|
|||||||
const MAX_BACKGROUND_TASKS: usize = 2;
|
const MAX_BACKGROUND_TASKS: usize = 2;
|
||||||
pub const CONFIG_SCHEMA_VERSION: u32 = 1;
|
pub const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
const ENV_INTEGRATED: &str = "METACRATE_AGENT_INTEGRATED";
|
#[cfg(test)]
|
||||||
const ENV_SPLIT: &str = "METACRATE_AGENT_SPLIT";
|
|
||||||
const ENV_LLM_ENDPOINT: &str = "METACRATE_AGENT_LLM_ENDPOINT_URL";
|
const ENV_LLM_ENDPOINT: &str = "METACRATE_AGENT_LLM_ENDPOINT_URL";
|
||||||
|
#[cfg(test)]
|
||||||
const ENV_LLM_API_KEY: &str = "METACRATE_AGENT_LLM_API_KEY";
|
const ENV_LLM_API_KEY: &str = "METACRATE_AGENT_LLM_API_KEY";
|
||||||
const ENV_LLM_API_KEY_FILE: &str = "METACRATE_AGENT_LLM_API_KEY_FILE";
|
|
||||||
const ENV_GRID_LOGIN_URL: &str = "METACRATE_AGENT_GRID_LOGIN_URL";
|
|
||||||
const ENV_GRID_AVATAR_NAME: &str = "METACRATE_AGENT_GRID_AVATAR_NAME";
|
|
||||||
const ENV_GRID_PASSWORD: &str = "METACRATE_AGENT_GRID_PASSWORD";
|
|
||||||
const ENV_GRID_PASSWORD_FILE: &str = "METACRATE_AGENT_GRID_PASSWORD_FILE";
|
|
||||||
const ENV_AUTHORIZED_AVATARS: &str = "METACRATE_AGENT_AUTHORIZED_AVATAR_UUIDS";
|
|
||||||
const ENV_STORAGE_PATH: &str = "METACRATE_AGENT_STORAGE_PATH";
|
|
||||||
const ENV_CONTROL_LISTEN: &str = "METACRATE_AGENT_CONTROL_LISTEN";
|
|
||||||
const ENV_CONTROL_OPERATOR_TOKEN: &str = "METACRATE_AGENT_CONTROL_OPERATOR_TOKEN";
|
|
||||||
const ENV_CONTROL_OPERATOR_TOKEN_FILE: &str = "METACRATE_AGENT_CONTROL_OPERATOR_TOKEN_FILE";
|
|
||||||
const ENV_CONTROL_OBSERVER_TOKEN: &str = "METACRATE_AGENT_CONTROL_OBSERVER_TOKEN";
|
|
||||||
const ENV_CONTROL_OBSERVER_TOKEN_FILE: &str = "METACRATE_AGENT_CONTROL_OBSERVER_TOKEN_FILE";
|
|
||||||
|
|
||||||
/// Wrapper that never reveals its contents through `Debug` or `Display` and
|
/// Wrapper that never reveals its contents through `Debug` or `Display` and
|
||||||
/// deliberately does not implement serialization.
|
/// deliberately does not implement serialization.
|
||||||
@@ -160,6 +149,7 @@ pub enum OperatingMode {
|
|||||||
pub struct LlmConnection {
|
pub struct LlmConnection {
|
||||||
pub endpoint_url: EndpointUrl,
|
pub endpoint_url: EndpointUrl,
|
||||||
pub api_key: SecretString,
|
pub api_key: SecretString,
|
||||||
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -345,6 +335,7 @@ impl AgentConfig {
|
|||||||
llm: RawLlm {
|
llm: RawLlm {
|
||||||
endpoint_url: Some(endpoint_url.into()),
|
endpoint_url: Some(endpoint_url.into()),
|
||||||
api_key: Some(api_key.into()),
|
api_key: Some(api_key.into()),
|
||||||
|
..RawLlm::default()
|
||||||
},
|
},
|
||||||
..FileConfig::default()
|
..FileConfig::default()
|
||||||
};
|
};
|
||||||
@@ -455,7 +446,7 @@ impl PlatformPaths {
|
|||||||
.get("LOCALAPPDATA")
|
.get("LOCALAPPDATA")
|
||||||
.map_or_else(|| PathBuf::from("data"), PathBuf::from);
|
.map_or_else(|| PathBuf::from("data"), PathBuf::from);
|
||||||
return Self {
|
return Self {
|
||||||
config_file: config.join("MetaCrate/grid-agent.json"),
|
config_file: config.join("MetaCrate/config.yml"),
|
||||||
data_directory: data.join("MetaCrate/grid-agent"),
|
data_directory: data.join("MetaCrate/grid-agent"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -464,7 +455,7 @@ impl PlatformPaths {
|
|||||||
.get("HOME")
|
.get("HOME")
|
||||||
.map_or_else(|| PathBuf::from("."), PathBuf::from);
|
.map_or_else(|| PathBuf::from("."), PathBuf::from);
|
||||||
return Self {
|
return Self {
|
||||||
config_file: home.join("Library/Application Support/MetaCrate/grid-agent.json"),
|
config_file: home.join("Library/Application Support/MetaCrate/config.yml"),
|
||||||
data_directory: home.join("Library/Application Support/MetaCrate/grid-agent"),
|
data_directory: home.join("Library/Application Support/MetaCrate/grid-agent"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -478,7 +469,7 @@ impl PlatformPaths {
|
|||||||
.get("XDG_DATA_HOME")
|
.get("XDG_DATA_HOME")
|
||||||
.map_or_else(|| home.join(".local/share"), PathBuf::from);
|
.map_or_else(|| home.join(".local/share"), PathBuf::from);
|
||||||
Self {
|
Self {
|
||||||
config_file: config.join("metacrate/grid-agent.json"),
|
config_file: config.join("metacrate/config.yml"),
|
||||||
data_directory: data.join("metacrate/grid-agent"),
|
data_directory: data.join("metacrate/grid-agent"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -710,6 +701,322 @@ impl fmt::Display for ConfigError {
|
|||||||
|
|
||||||
impl Error for ConfigError {}
|
impl Error for ConfigError {}
|
||||||
|
|
||||||
|
/// Editable connection and authorization subset of `config.yml`.
|
||||||
|
///
|
||||||
|
/// Unknown settings are retained across saves, allowing the preferences UI to
|
||||||
|
/// update connection setup without resetting behavior, limits, or storage.
|
||||||
|
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AgentPreferences {
|
||||||
|
pub schema_version: Option<u32>,
|
||||||
|
pub integrated: Option<bool>,
|
||||||
|
pub split: Option<bool>,
|
||||||
|
pub llm: ConnectionPreferences,
|
||||||
|
pub grid: GridPreferences,
|
||||||
|
pub authorized_avatar_uuids: Option<Vec<String>>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: BTreeMap<String, serde_yaml_ng::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for AgentPreferences {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("AgentPreferences")
|
||||||
|
.field("schema_version", &self.schema_version)
|
||||||
|
.field("integrated", &self.integrated)
|
||||||
|
.field("split", &self.split)
|
||||||
|
.field("llm_endpoint_configured", &self.llm.endpoint_url.is_some())
|
||||||
|
.field("llm_api_key_configured", &self.llm.api_key.is_some())
|
||||||
|
.field("grid_login_configured", &self.grid.login_url.is_some())
|
||||||
|
.field("grid_avatar_configured", &self.grid.avatar_name.is_some())
|
||||||
|
.field("grid_password_configured", &self.grid.password.is_some())
|
||||||
|
.field(
|
||||||
|
"authorized_avatar_count",
|
||||||
|
&self.authorized_avatar_uuids.as_ref().map_or(0, Vec::len),
|
||||||
|
)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ConnectionPreferences {
|
||||||
|
pub endpoint_url: Option<String>,
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: BTreeMap<String, serde_yaml_ng::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct GridPreferences {
|
||||||
|
pub login_url: Option<String>,
|
||||||
|
pub avatar_name: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: BTreeMap<String, serde_yaml_ng::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PreferencesSummary {
|
||||||
|
pub mode: OperatingMode,
|
||||||
|
pub llm_endpoint: Option<String>,
|
||||||
|
pub llm_api_key_configured: bool,
|
||||||
|
pub llm_model: Option<String>,
|
||||||
|
pub grid_login_url: Option<String>,
|
||||||
|
pub grid_avatar_name: Option<String>,
|
||||||
|
pub grid_password_configured: bool,
|
||||||
|
pub privileged_users: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentPreferences {
|
||||||
|
/// Loads editable preferences, or returns initialized defaults when the
|
||||||
|
/// configuration file does not exist.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when an existing file cannot be read or parsed.
|
||||||
|
pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Self {
|
||||||
|
schema_version: Some(CONFIG_SCHEMA_VERSION),
|
||||||
|
integrated: Some(true),
|
||||||
|
..Self::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let bytes = read_bounded_regular_file("configuration file", path, MAX_CONFIG_BYTES)?;
|
||||||
|
parse_document(&bytes, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn summary(&self) -> PreferencesSummary {
|
||||||
|
let mode = match (
|
||||||
|
self.integrated.unwrap_or(false),
|
||||||
|
self.split.unwrap_or(false),
|
||||||
|
) {
|
||||||
|
(true, false) => OperatingMode::Integrated,
|
||||||
|
(false, true) => OperatingMode::SplitService,
|
||||||
|
_ => OperatingMode::OfflineFake,
|
||||||
|
};
|
||||||
|
PreferencesSummary {
|
||||||
|
mode,
|
||||||
|
llm_endpoint: self.llm.endpoint_url.clone(),
|
||||||
|
llm_api_key_configured: self.llm.api_key.is_some(),
|
||||||
|
llm_model: self.llm.model.clone(),
|
||||||
|
grid_login_url: self.grid.login_url.clone(),
|
||||||
|
grid_avatar_name: self.grid.avatar_name.clone(),
|
||||||
|
grid_password_configured: self.grid.password.is_some(),
|
||||||
|
privileged_users: self.authorized_avatar_uuids.clone().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_mode(&mut self, mode: OperatingMode) {
|
||||||
|
self.integrated = Some(mode == OperatingMode::Integrated);
|
||||||
|
self.split = Some(mode == OperatingMode::SplitService);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_llm(&mut self, endpoint_url: String, api_key: Option<String>) {
|
||||||
|
self.llm.endpoint_url = Some(endpoint_url);
|
||||||
|
if let Some(api_key) = api_key {
|
||||||
|
self.llm.api_key = Some(api_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_llm_model(&mut self, model: String) {
|
||||||
|
self.llm.model = (!model.trim().is_empty()).then_some(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_grid(&mut self, login_url: String, avatar_name: String, password: Option<String>) {
|
||||||
|
self.grid.login_url = Some(login_url);
|
||||||
|
self.grid.avatar_name = Some(avatar_name);
|
||||||
|
if let Some(password) = password {
|
||||||
|
self.grid.password = Some(password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_privileged_users(&mut self, users: Vec<String>) {
|
||||||
|
self.authorized_avatar_uuids = Some(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates and securely writes the preferences as YAML.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for invalid values, unsafe paths, serialization
|
||||||
|
/// failures, or filesystem failures.
|
||||||
|
pub fn save(&mut self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.schema_version = Some(CONFIG_SCHEMA_VERSION);
|
||||||
|
validate_preferences(self)?;
|
||||||
|
let encoded =
|
||||||
|
serde_yaml_ng::to_string(self).map_err(|error| ConfigError::InvalidSchema {
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: error.to_string(),
|
||||||
|
})?;
|
||||||
|
if encoded.len() > usize::try_from(MAX_CONFIG_BYTES).unwrap_or(usize::MAX) {
|
||||||
|
return Err(ConfigError::InvalidSchema {
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: "serialized configuration exceeds the size limit".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|error| ConfigError::Io {
|
||||||
|
field: "configuration directory",
|
||||||
|
path: parent.to_owned(),
|
||||||
|
reason: error.to_string(),
|
||||||
|
})?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).map_err(
|
||||||
|
|error| ConfigError::Io {
|
||||||
|
field: "configuration directory permissions",
|
||||||
|
path: parent.to_owned(),
|
||||||
|
reason: error.to_string(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path
|
||||||
|
.symlink_metadata()
|
||||||
|
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidSchema {
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: "configuration path must not be a symbolic link".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut options = fs::OpenOptions::new();
|
||||||
|
options.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt as _;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
let mut file = options.open(path).map_err(|error| ConfigError::Io {
|
||||||
|
field: "configuration file",
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: error.to_string(),
|
||||||
|
})?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||||
|
.map_err(|error| ConfigError::Io {
|
||||||
|
field: "configuration file permissions",
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: error.to_string(),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
file.write_all(encoded.as_bytes())
|
||||||
|
.map_err(|error| ConfigError::Io {
|
||||||
|
field: "configuration file",
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: error.to_string(),
|
||||||
|
})?;
|
||||||
|
file.sync_all().map_err(|error| ConfigError::Io {
|
||||||
|
field: "configuration file",
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: error.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports supported values from a legacy `.env` file into memory.
|
||||||
|
///
|
||||||
|
/// The caller must invoke [`Self::save`] to persist the result.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the legacy file is unsafe, unreadable, too large,
|
||||||
|
/// non-UTF-8, or contains a malformed assignment.
|
||||||
|
pub fn import_dotenv(&mut self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let bytes = read_bounded_regular_file("legacy environment file", path, MAX_CONFIG_BYTES)?;
|
||||||
|
let text = String::from_utf8(bytes).map_err(|_| ConfigError::InvalidSchema {
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: "legacy environment file must be UTF-8".into(),
|
||||||
|
})?;
|
||||||
|
let mut values = BTreeMap::new();
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line = line.strip_prefix("export ").unwrap_or(line);
|
||||||
|
let Some((name, value)) = line.split_once('=') else {
|
||||||
|
return Err(ConfigError::InvalidSchema {
|
||||||
|
path: path.to_owned(),
|
||||||
|
reason: format!("invalid assignment on line {}", index + 1),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
let value = if value.len() >= 2
|
||||||
|
&& ((value.starts_with('"') && value.ends_with('"'))
|
||||||
|
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||||
|
{
|
||||||
|
&value[1..value.len() - 1]
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
};
|
||||||
|
values.insert(name.trim(), value.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(value) = values.get("OPENAPI_URL") {
|
||||||
|
self.llm.endpoint_url = Some(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = values.get("OPENAPI_KEY") {
|
||||||
|
self.llm.api_key = Some(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = values.get("OPENAPI_MODEL") {
|
||||||
|
self.llm.model = Some(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = values.get("GRID_LOGIN_URL") {
|
||||||
|
self.grid.login_url = Some(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = values.get("GRID_USER") {
|
||||||
|
self.grid.avatar_name = Some(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = values.get("GRID_PASSWORD") {
|
||||||
|
self.grid.password = Some(value.clone());
|
||||||
|
}
|
||||||
|
validate_preferences(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_preferences(preferences: &AgentPreferences) -> Result<(), ConfigError> {
|
||||||
|
if preferences.integrated.unwrap_or(false) && preferences.split.unwrap_or(false) {
|
||||||
|
return Err(ConfigError::ConflictingModes);
|
||||||
|
}
|
||||||
|
if let Some(endpoint) = &preferences.llm.endpoint_url {
|
||||||
|
EndpointUrl::parse("llm.endpoint_url", endpoint)?;
|
||||||
|
}
|
||||||
|
if let Some(secret) = &preferences.llm.api_key {
|
||||||
|
validate_secret("llm.api_key", secret)?;
|
||||||
|
}
|
||||||
|
if preferences.llm.model.as_ref().is_some_and(|model| {
|
||||||
|
model.trim().is_empty() || model.len() > 256 || model.chars().any(char::is_control)
|
||||||
|
}) {
|
||||||
|
return Err(ConfigError::InvalidSchema {
|
||||||
|
path: PathBuf::from("config.yml"),
|
||||||
|
reason: "llm.model must be a nonempty printable value of at most 256 bytes".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(endpoint) = &preferences.grid.login_url {
|
||||||
|
EndpointUrl::parse("grid.login_url", endpoint)?;
|
||||||
|
}
|
||||||
|
if let Some(secret) = &preferences.grid.password {
|
||||||
|
validate_secret("grid.password", secret)?;
|
||||||
|
}
|
||||||
|
for value in preferences.authorized_avatar_uuids.iter().flatten() {
|
||||||
|
UUID::new_with_string(value.clone()).map_err(|_| ConfigError::InvalidUuid {
|
||||||
|
value: value.clone(),
|
||||||
|
reason: "expected a canonical UUID",
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize)]
|
#[derive(Clone, Default, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
struct FileConfig {
|
struct FileConfig {
|
||||||
@@ -735,6 +1042,7 @@ struct FileConfig {
|
|||||||
struct RawLlm {
|
struct RawLlm {
|
||||||
endpoint_url: Option<String>,
|
endpoint_url: Option<String>,
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize)]
|
#[derive(Clone, Default, Deserialize)]
|
||||||
@@ -851,9 +1159,25 @@ struct RawInteraction {
|
|||||||
|
|
||||||
fn read_config(path: &Path) -> Result<FileConfig, ConfigError> {
|
fn read_config(path: &Path) -> Result<FileConfig, ConfigError> {
|
||||||
let bytes = read_bounded_regular_file("configuration file", path, MAX_CONFIG_BYTES)?;
|
let bytes = read_bounded_regular_file("configuration file", path, MAX_CONFIG_BYTES)?;
|
||||||
serde_json::from_slice(&bytes).map_err(|error| ConfigError::InvalidSchema {
|
parse_document(&bytes, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_document<T: serde::de::DeserializeOwned>(
|
||||||
|
bytes: &[u8],
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<T, ConfigError> {
|
||||||
|
let result = if path
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
|
||||||
|
{
|
||||||
|
serde_json::from_slice(bytes).map_err(|error| error.to_string())
|
||||||
|
} else {
|
||||||
|
serde_yaml_ng::from_slice(bytes).map_err(|error| error.to_string())
|
||||||
|
};
|
||||||
|
result.map_err(|reason| ConfigError::InvalidSchema {
|
||||||
path: path.to_owned(),
|
path: path.to_owned(),
|
||||||
reason: error.to_string(),
|
reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,12 +1195,8 @@ fn resolve<E: Environment>(
|
|||||||
supported: CONFIG_SCHEMA_VERSION,
|
supported: CONFIG_SCHEMA_VERSION,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let integrated = environment_boolean(environment, ENV_INTEGRATED)?
|
let integrated = raw.integrated.unwrap_or(false);
|
||||||
.or(raw.integrated)
|
let split = raw.split.unwrap_or(false);
|
||||||
.unwrap_or(false);
|
|
||||||
let split = environment_boolean(environment, ENV_SPLIT)?
|
|
||||||
.or(raw.split)
|
|
||||||
.unwrap_or(false);
|
|
||||||
let mode = match (integrated, split) {
|
let mode = match (integrated, split) {
|
||||||
(true, true) => return Err(ConfigError::ConflictingModes),
|
(true, true) => return Err(ConfigError::ConflictingModes),
|
||||||
(true, false) => OperatingMode::Integrated,
|
(true, false) => OperatingMode::Integrated,
|
||||||
@@ -887,16 +1207,13 @@ fn resolve<E: Environment>(
|
|||||||
let base = config_path
|
let base = config_path
|
||||||
.and_then(Path::parent)
|
.and_then(Path::parent)
|
||||||
.unwrap_or_else(|| Path::new("."));
|
.unwrap_or_else(|| Path::new("."));
|
||||||
let endpoint = environment
|
let endpoint = raw.llm.endpoint_url.ok_or(ConfigError::Missing {
|
||||||
.get(ENV_LLM_ENDPOINT)
|
|
||||||
.or(raw.llm.endpoint_url)
|
|
||||||
.ok_or(ConfigError::Missing {
|
|
||||||
field: "llm.endpoint_url",
|
field: "llm.endpoint_url",
|
||||||
required_for: "all modes",
|
required_for: "all modes",
|
||||||
})?;
|
})?;
|
||||||
let api_key = secret_from_layers(
|
let api_key = secret_from_layers(
|
||||||
environment.get(ENV_LLM_API_KEY),
|
None,
|
||||||
environment.get(ENV_LLM_API_KEY_FILE).map(PathBuf::from),
|
None,
|
||||||
raw.llm.api_key,
|
raw.llm.api_key,
|
||||||
raw.secret_files.llm_api_key,
|
raw.secret_files.llm_api_key,
|
||||||
base,
|
base,
|
||||||
@@ -905,19 +1222,32 @@ fn resolve<E: Environment>(
|
|||||||
let llm = LlmConnection {
|
let llm = LlmConnection {
|
||||||
endpoint_url: EndpointUrl::parse("llm.endpoint_url", &endpoint)?,
|
endpoint_url: EndpointUrl::parse("llm.endpoint_url", &endpoint)?,
|
||||||
api_key,
|
api_key,
|
||||||
|
model: raw
|
||||||
|
.llm
|
||||||
|
.model
|
||||||
|
.map(|model| model.trim().to_owned())
|
||||||
|
.filter(|model| !model.is_empty()),
|
||||||
};
|
};
|
||||||
|
if llm
|
||||||
|
.model
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|model| model.len() > 256 || model.chars().any(char::is_control))
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidSchema {
|
||||||
|
path: config_path
|
||||||
|
.unwrap_or_else(|| Path::new("config.yml"))
|
||||||
|
.to_owned(),
|
||||||
|
reason: "llm.model must be printable and at most 256 bytes".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let login_url = environment.get(ENV_GRID_LOGIN_URL).or(raw.grid.login_url);
|
let login_url = raw.grid.login_url;
|
||||||
let avatar_name = environment
|
let avatar_name = raw.grid.avatar_name;
|
||||||
.get(ENV_GRID_AVATAR_NAME)
|
let direct_password = raw.grid.password;
|
||||||
.or(raw.grid.avatar_name);
|
|
||||||
let direct_password = environment.get(ENV_GRID_PASSWORD).or(raw.grid.password);
|
|
||||||
let environment_password_file = environment.get(ENV_GRID_PASSWORD_FILE).map(PathBuf::from);
|
|
||||||
let grid = if mode == OperatingMode::OfflineFake
|
let grid = if mode == OperatingMode::OfflineFake
|
||||||
&& login_url.is_none()
|
&& login_url.is_none()
|
||||||
&& avatar_name.is_none()
|
&& avatar_name.is_none()
|
||||||
&& direct_password.is_none()
|
&& direct_password.is_none()
|
||||||
&& environment_password_file.is_none()
|
|
||||||
&& raw.secret_files.grid_password.is_none()
|
&& raw.secret_files.grid_password.is_none()
|
||||||
{
|
{
|
||||||
None
|
None
|
||||||
@@ -941,7 +1271,7 @@ fn resolve<E: Environment>(
|
|||||||
avatar_name,
|
avatar_name,
|
||||||
password: secret_from_layers(
|
password: secret_from_layers(
|
||||||
direct_password,
|
direct_password,
|
||||||
environment_password_file,
|
None,
|
||||||
None,
|
None,
|
||||||
raw.secret_files.grid_password,
|
raw.secret_files.grid_password,
|
||||||
base,
|
base,
|
||||||
@@ -950,11 +1280,7 @@ fn resolve<E: Environment>(
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let authorized_values = environment
|
let authorized_values = raw.authorized_avatar_uuids.unwrap_or_default();
|
||||||
.get(ENV_AUTHORIZED_AVATARS)
|
|
||||||
.map(|value| value.split(',').map(str::trim).map(str::to_owned).collect())
|
|
||||||
.or(raw.authorized_avatar_uuids)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let authorized_avatar_uuids = parse_authorized_avatars(authorized_values)?;
|
let authorized_avatar_uuids = parse_authorized_avatars(authorized_values)?;
|
||||||
|
|
||||||
let defaults = Limits::default();
|
let defaults = Limits::default();
|
||||||
@@ -1128,28 +1454,24 @@ fn resolve<E: Environment>(
|
|||||||
.unwrap_or(interaction_defaults.max_debounce_fragments),
|
.unwrap_or(interaction_defaults.max_debounce_fragments),
|
||||||
};
|
};
|
||||||
let control_defaults = ControlSettings::default();
|
let control_defaults = ControlSettings::default();
|
||||||
let listen_text = environment
|
let listen_text = raw
|
||||||
.get(ENV_CONTROL_LISTEN)
|
.control
|
||||||
.or(raw.control.listen)
|
.listen
|
||||||
.unwrap_or_else(|| control_defaults.listen.to_string());
|
.unwrap_or_else(|| control_defaults.listen.to_string());
|
||||||
let listen = listen_text
|
let listen = listen_text
|
||||||
.parse::<SocketAddr>()
|
.parse::<SocketAddr>()
|
||||||
.map_err(|_| ConfigError::InvalidControl)?;
|
.map_err(|_| ConfigError::InvalidControl)?;
|
||||||
let operator_token = optional_secret_from_layers(
|
let operator_token = optional_secret_from_layers(
|
||||||
environment.get(ENV_CONTROL_OPERATOR_TOKEN),
|
None,
|
||||||
environment
|
None,
|
||||||
.get(ENV_CONTROL_OPERATOR_TOKEN_FILE)
|
|
||||||
.map(PathBuf::from),
|
|
||||||
raw.control.operator_token,
|
raw.control.operator_token,
|
||||||
raw.secret_files.control_operator_token,
|
raw.secret_files.control_operator_token,
|
||||||
base,
|
base,
|
||||||
"control.operator_token",
|
"control.operator_token",
|
||||||
)?;
|
)?;
|
||||||
let observer_token = optional_secret_from_layers(
|
let observer_token = optional_secret_from_layers(
|
||||||
environment.get(ENV_CONTROL_OBSERVER_TOKEN),
|
None,
|
||||||
environment
|
None,
|
||||||
.get(ENV_CONTROL_OBSERVER_TOKEN_FILE)
|
|
||||||
.map(PathBuf::from),
|
|
||||||
raw.control.observer_token,
|
raw.control.observer_token,
|
||||||
raw.secret_files.control_observer_token,
|
raw.secret_files.control_observer_token,
|
||||||
base,
|
base,
|
||||||
@@ -1186,10 +1508,8 @@ fn resolve<E: Environment>(
|
|||||||
authorized_avatar_uuids,
|
authorized_avatar_uuids,
|
||||||
timeouts,
|
timeouts,
|
||||||
limits,
|
limits,
|
||||||
storage_path: environment
|
storage_path: raw
|
||||||
.get(ENV_STORAGE_PATH)
|
.storage_path
|
||||||
.map(PathBuf::from)
|
|
||||||
.or(raw.storage_path)
|
|
||||||
.unwrap_or_else(|| PlatformPaths::from_environment(environment).data_directory),
|
.unwrap_or_else(|| PlatformPaths::from_environment(environment).data_directory),
|
||||||
behavior: BehaviorSettings {
|
behavior: BehaviorSettings {
|
||||||
heartbeat: checked_duration(
|
heartbeat: checked_duration(
|
||||||
@@ -1425,20 +1745,6 @@ fn validate_secret(field: &'static str, value: &str) -> Result<(), ConfigError>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn environment_boolean<E: Environment>(
|
|
||||||
environment: &E,
|
|
||||||
field: &'static str,
|
|
||||||
) -> Result<Option<bool>, ConfigError> {
|
|
||||||
environment
|
|
||||||
.get(field)
|
|
||||||
.map(|value| match value.to_ascii_lowercase().as_str() {
|
|
||||||
"true" | "1" => Ok(true),
|
|
||||||
"false" | "0" => Ok(false),
|
|
||||||
_ => Err(ConfigError::InvalidBoolean { field, value }),
|
|
||||||
})
|
|
||||||
.transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn checked_duration(
|
fn checked_duration(
|
||||||
field: &'static str,
|
field: &'static str,
|
||||||
seconds: u64,
|
seconds: u64,
|
||||||
@@ -1523,7 +1829,6 @@ fn check_limit(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::io::Write as _;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(1);
|
static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(1);
|
||||||
@@ -1559,7 +1864,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn environment_overrides_file_and_secrets_are_redacted() {
|
fn file_configuration_is_authoritative_and_secrets_are_redacted() {
|
||||||
let path = temporary_file(
|
let path = temporary_file(
|
||||||
"precedence.json",
|
"precedence.json",
|
||||||
r#"{
|
r#"{
|
||||||
@@ -1572,13 +1877,13 @@ mod tests {
|
|||||||
let loader_diagnostic = format!("{loader:?}");
|
let loader_diagnostic = format!("{loader:?}");
|
||||||
assert!(!loader_diagnostic.contains("environment-key"));
|
assert!(!loader_diagnostic.contains("environment-key"));
|
||||||
let config = loader.load().expect("valid layered configuration");
|
let config = loader.load().expect("valid layered configuration");
|
||||||
assert_eq!(config.llm.api_key.expose_secret(), "environment-key");
|
assert_eq!(config.llm.api_key.expose_secret(), "file-key");
|
||||||
assert!(
|
assert!(
|
||||||
config
|
config
|
||||||
.llm
|
.llm
|
||||||
.endpoint_url
|
.endpoint_url
|
||||||
.expose_url()
|
.expose_url()
|
||||||
.contains("llm.example.invalid")
|
.contains("file.invalid")
|
||||||
);
|
);
|
||||||
let diagnostic = format!("{config:?}");
|
let diagnostic = format!("{config:?}");
|
||||||
assert!(!diagnostic.contains("environment-key"));
|
assert!(!diagnostic.contains("environment-key"));
|
||||||
@@ -1587,7 +1892,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn secret_file_overrides_inline_secret_and_direct_environment_wins_last() {
|
fn secret_file_overrides_inline_and_environment_is_ignored() {
|
||||||
let secret = temporary_file("precedence-api-key", "secret-file-key\n");
|
let secret = temporary_file("precedence-api-key", "secret-file-key\n");
|
||||||
let document = serde_json::json!({
|
let document = serde_json::json!({
|
||||||
"llm": {
|
"llm": {
|
||||||
@@ -1615,7 +1920,7 @@ mod tests {
|
|||||||
.with_environment(direct_environment)
|
.with_environment(direct_environment)
|
||||||
.load()
|
.load()
|
||||||
.expect("direct environment layer resolves");
|
.expect("direct environment layer resolves");
|
||||||
assert_eq!(config.llm.api_key.expose_secret(), "direct-environment-key");
|
assert_eq!(config.llm.api_key.expose_secret(), "secret-file-key");
|
||||||
let _ = fs::remove_file(secret);
|
let _ = fs::remove_file(secret);
|
||||||
let _ = fs::remove_file(config_path);
|
let _ = fs::remove_file(config_path);
|
||||||
}
|
}
|
||||||
@@ -1636,19 +1941,19 @@ mod tests {
|
|||||||
supported: CONFIG_SCHEMA_VERSION
|
supported: CONFIG_SCHEMA_VERSION
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
let environment = MapEnvironment::from_pairs([
|
let configured = temporary_file(
|
||||||
(ENV_LLM_ENDPOINT, "https://llm.invalid/chat"),
|
"storage.yml",
|
||||||
(ENV_LLM_API_KEY, "placeholder"),
|
"llm:\n endpoint_url: https://llm.invalid/chat\n api_key: placeholder\nstorage_path: operator-data\n",
|
||||||
(ENV_STORAGE_PATH, "operator-data"),
|
);
|
||||||
("HOME", "/operator"),
|
let environment = MapEnvironment::from_pairs([("HOME", "/operator")]);
|
||||||
]);
|
|
||||||
let config = ConfigLoader::new()
|
let config = ConfigLoader::new()
|
||||||
|
.with_file(configured)
|
||||||
.with_environment(environment.clone())
|
.with_environment(environment.clone())
|
||||||
.load()
|
.load()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(config.storage_path, PathBuf::from("operator-data"));
|
assert_eq!(config.storage_path, PathBuf::from("operator-data"));
|
||||||
let paths = PlatformPaths::from_environment(&environment);
|
let paths = PlatformPaths::from_environment(&environment);
|
||||||
assert!(paths.config_file.ends_with("grid-agent.json"));
|
assert!(paths.config_file.ends_with("config.yml"));
|
||||||
assert!(paths.data_directory.ends_with("grid-agent"));
|
assert!(paths.data_directory.ends_with("grid-agent"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1682,65 +1987,52 @@ mod tests {
|
|||||||
fn invalid_urls_uuids_limits_and_wildcards_fail_fast() {
|
fn invalid_urls_uuids_limits_and_wildcards_fail_fast() {
|
||||||
assert!(AgentConfig::offline("file:///tmp/socket", "key").is_err());
|
assert!(AgentConfig::offline("file:///tmp/socket", "key").is_err());
|
||||||
|
|
||||||
let mut wildcard = offline_environment();
|
|
||||||
wildcard.insert(ENV_AUTHORIZED_AVATARS, "*");
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ConfigLoader::new().with_environment(wildcard).load(),
|
parse_authorized_avatars(vec!["*".into()]),
|
||||||
Err(ConfigError::InvalidUuid { .. })
|
Err(ConfigError::InvalidUuid { .. })
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut malformed = offline_environment();
|
|
||||||
malformed.insert(ENV_AUTHORIZED_AVATARS, "not-a-uuid");
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ConfigLoader::new().with_environment(malformed).load(),
|
parse_authorized_avatars(vec!["not-a-uuid".into()]),
|
||||||
Err(ConfigError::InvalidUuid { .. })
|
Err(ConfigError::InvalidUuid { .. })
|
||||||
));
|
));
|
||||||
|
|
||||||
let path = temporary_file("unsafe-limit.json", r#"{"limits":{"control_queue":0}}"#);
|
let path = temporary_file(
|
||||||
|
"unsafe-limit.json",
|
||||||
|
r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"limits":{"control_queue":0}}"#,
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ConfigLoader::new()
|
ConfigLoader::new().with_file(&path).load(),
|
||||||
.with_file(&path)
|
|
||||||
.with_environment(offline_environment())
|
|
||||||
.load(),
|
|
||||||
Err(ConfigError::UnsafeLimit { .. })
|
Err(ConfigError::UnsafeLimit { .. })
|
||||||
));
|
));
|
||||||
let _ = fs::remove_file(path);
|
let _ = fs::remove_file(path);
|
||||||
|
|
||||||
let reconnect = temporary_file(
|
let reconnect = temporary_file(
|
||||||
"unsafe-reconnect.json",
|
"unsafe-reconnect.json",
|
||||||
r#"{"reconnect":{"initial_delay_milliseconds":0}}"#,
|
r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"reconnect":{"initial_delay_milliseconds":0}}"#,
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ConfigLoader::new()
|
ConfigLoader::new().with_file(&reconnect).load(),
|
||||||
.with_file(&reconnect)
|
|
||||||
.with_environment(offline_environment())
|
|
||||||
.load(),
|
|
||||||
Err(ConfigError::InvalidReconnect)
|
Err(ConfigError::InvalidReconnect)
|
||||||
));
|
));
|
||||||
let _ = fs::remove_file(reconnect);
|
let _ = fs::remove_file(reconnect);
|
||||||
|
|
||||||
let conversation = temporary_file(
|
let conversation = temporary_file(
|
||||||
"unsafe-conversation.json",
|
"unsafe-conversation.json",
|
||||||
r#"{"conversation":{"max_active_sessions":0}}"#,
|
r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"conversation":{"max_active_sessions":0}}"#,
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ConfigLoader::new()
|
ConfigLoader::new().with_file(&conversation).load(),
|
||||||
.with_file(&conversation)
|
|
||||||
.with_environment(offline_environment())
|
|
||||||
.load(),
|
|
||||||
Err(ConfigError::InvalidConversationMemory)
|
Err(ConfigError::InvalidConversationMemory)
|
||||||
));
|
));
|
||||||
let _ = fs::remove_file(conversation);
|
let _ = fs::remove_file(conversation);
|
||||||
|
|
||||||
let interaction = temporary_file(
|
let interaction = temporary_file(
|
||||||
"unsafe-interaction.json",
|
"unsafe-interaction.json",
|
||||||
r#"{"interaction":{"max_concurrent_inference":0}}"#,
|
r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"interaction":{"max_concurrent_inference":0}}"#,
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ConfigLoader::new()
|
ConfigLoader::new().with_file(&interaction).load(),
|
||||||
.with_file(&interaction)
|
|
||||||
.with_environment(offline_environment())
|
|
||||||
.load(),
|
|
||||||
Err(ConfigError::InvalidInteraction)
|
Err(ConfigError::InvalidInteraction)
|
||||||
));
|
));
|
||||||
let _ = fs::remove_file(interaction);
|
let _ = fs::remove_file(interaction);
|
||||||
@@ -1748,24 +2040,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn conflicting_live_modes_and_missing_live_fields_are_rejected() {
|
fn conflicting_live_modes_and_missing_live_fields_are_rejected() {
|
||||||
let both = MapEnvironment::from_pairs([
|
let both = temporary_file(
|
||||||
(ENV_INTEGRATED, "true"),
|
"both.yml",
|
||||||
(ENV_SPLIT, "true"),
|
"integrated: true\nsplit: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: key }\n",
|
||||||
(ENV_LLM_ENDPOINT, "https://llm.invalid/chat"),
|
);
|
||||||
(ENV_LLM_API_KEY, "key"),
|
|
||||||
]);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ConfigLoader::new().with_environment(both).load(),
|
ConfigLoader::new().with_file(both).load(),
|
||||||
Err(ConfigError::ConflictingModes)
|
Err(ConfigError::ConflictingModes)
|
||||||
);
|
);
|
||||||
|
|
||||||
let missing_grid = MapEnvironment::from_pairs([
|
let missing_grid = temporary_file(
|
||||||
(ENV_INTEGRATED, "true"),
|
"missing-grid.yml",
|
||||||
(ENV_LLM_ENDPOINT, "https://llm.invalid/chat"),
|
"integrated: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: key }\n",
|
||||||
(ENV_LLM_API_KEY, "key"),
|
);
|
||||||
]);
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ConfigLoader::new().with_environment(missing_grid).load(),
|
ConfigLoader::new().with_file(missing_grid).load(),
|
||||||
Err(ConfigError::Missing {
|
Err(ConfigError::Missing {
|
||||||
field: "grid.login_url",
|
field: "grid.login_url",
|
||||||
..
|
..
|
||||||
@@ -1775,18 +2064,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn split_control_requires_distinct_tokens_and_tls_for_remote_bind() {
|
fn split_control_requires_distinct_tokens_and_tls_for_remote_bind() {
|
||||||
let split = MapEnvironment::from_pairs([
|
let split = temporary_file(
|
||||||
(ENV_SPLIT, "true"),
|
"split.yml",
|
||||||
(ENV_LLM_ENDPOINT, "https://llm.invalid/chat"),
|
"split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { operator_token: operator-capability, observer_token: observer-capability }\n",
|
||||||
(ENV_LLM_API_KEY, "llm-key"),
|
);
|
||||||
(ENV_GRID_LOGIN_URL, "https://grid.invalid/login"),
|
|
||||||
(ENV_GRID_AVATAR_NAME, "Control Agent"),
|
|
||||||
(ENV_GRID_PASSWORD, "grid-password"),
|
|
||||||
(ENV_CONTROL_OPERATOR_TOKEN, "operator-capability"),
|
|
||||||
(ENV_CONTROL_OBSERVER_TOKEN, "observer-capability"),
|
|
||||||
]);
|
|
||||||
let config = ConfigLoader::new()
|
let config = ConfigLoader::new()
|
||||||
.with_environment(split.clone())
|
.with_file(split)
|
||||||
.load()
|
.load()
|
||||||
.expect("bounded loopback split control");
|
.expect("bounded loopback split control");
|
||||||
assert_eq!(config.mode, OperatingMode::SplitService);
|
assert_eq!(config.mode, OperatingMode::SplitService);
|
||||||
@@ -1800,19 +2083,21 @@ mod tests {
|
|||||||
assert!(!diagnostic.contains(secret));
|
assert!(!diagnostic.contains(secret));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut reused = split.clone();
|
let reused = temporary_file(
|
||||||
reused.insert(ENV_CONTROL_OPERATOR_TOKEN, "llm-key");
|
"reused.yml",
|
||||||
|
"split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { operator_token: llm-key, observer_token: observer-capability }\n",
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ConfigLoader::new().with_environment(reused).load(),
|
ConfigLoader::new().with_file(reused).load(),
|
||||||
Err(ConfigError::InvalidControl)
|
Err(ConfigError::InvalidControl)
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut remote_plaintext = split;
|
let remote_plaintext = temporary_file(
|
||||||
remote_plaintext.insert(ENV_CONTROL_LISTEN, "0.0.0.0:7943");
|
"remote.yml",
|
||||||
|
"split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { listen: '0.0.0.0:7943', operator_token: operator-capability }\n",
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ConfigLoader::new()
|
ConfigLoader::new().with_file(remote_plaintext).load(),
|
||||||
.with_environment(remote_plaintext)
|
|
||||||
.load(),
|
|
||||||
Err(ConfigError::InvalidControl)
|
Err(ConfigError::InvalidControl)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1820,11 +2105,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn secret_file_is_bounded_regular_and_trailing_newline_is_removed() {
|
fn secret_file_is_bounded_regular_and_trailing_newline_is_removed() {
|
||||||
let secret = temporary_file("api-key", "secret-from-file\n");
|
let secret = temporary_file("api-key", "secret-from-file\n");
|
||||||
let mut environment =
|
let document = serde_json::json!({"llm":{"endpoint_url":"https://llm.example.invalid/chat"},"secret_files":{"llm_api_key":secret}});
|
||||||
MapEnvironment::from_pairs([(ENV_LLM_ENDPOINT, "https://llm.example.invalid/chat")]);
|
let config_path = temporary_file("secret-file.json", &document.to_string());
|
||||||
environment.insert(ENV_LLM_API_KEY_FILE, secret.to_string_lossy());
|
|
||||||
let config = ConfigLoader::new()
|
let config = ConfigLoader::new()
|
||||||
.with_environment(environment)
|
.with_file(config_path)
|
||||||
.load()
|
.load()
|
||||||
.expect("secret file loads");
|
.expect("secret file loads");
|
||||||
assert_eq!(config.llm.api_key.expose_secret(), "secret-from-file");
|
assert_eq!(config.llm.api_key.expose_secret(), "secret-from-file");
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ mod vision_tests;
|
|||||||
|
|
||||||
pub use acceptance::{
|
pub use acceptance::{
|
||||||
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, AcceptanceError,
|
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, AcceptanceError,
|
||||||
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns,
|
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, run_deterministic_acceptance,
|
||||||
run_deterministic_acceptance,
|
|
||||||
};
|
};
|
||||||
pub use backend::{
|
pub use backend::{
|
||||||
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
|
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
|
||||||
@@ -85,9 +84,10 @@ pub use build::{
|
|||||||
BuildService, BuildShape, BuildToolBackend, BuildValidation, PrimReceipt, build_policy_tools,
|
BuildService, BuildShape, BuildToolBackend, BuildValidation, PrimReceipt, build_policy_tools,
|
||||||
};
|
};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
AgentConfig, BehaviorSettings, CONFIG_SCHEMA_VERSION, ConfigError, ConfigLoader,
|
AgentConfig, AgentPreferences, BehaviorSettings, CONFIG_SCHEMA_VERSION, ConfigError,
|
||||||
ControlSettings, ConversationSettings, EndpointUrl, Environment, GridConnection, Limits,
|
ConfigLoader, ConnectionPreferences, ControlSettings, ConversationSettings, EndpointUrl,
|
||||||
LlmConnection, MapEnvironment, OperatingMode, PlatformPaths, RemoteTlsSettings, SecretString,
|
Environment, GridConnection, GridPreferences, Limits, LlmConnection, MapEnvironment,
|
||||||
|
OperatingMode, PlatformPaths, PreferencesSummary, RemoteTlsSettings, SecretString,
|
||||||
StdEnvironment, Timeouts,
|
StdEnvironment, Timeouts,
|
||||||
};
|
};
|
||||||
pub use control_plane::{
|
pub use control_plane::{
|
||||||
@@ -175,8 +175,8 @@ pub use tool_loop::{
|
|||||||
};
|
};
|
||||||
pub use tui::{
|
pub use tui::{
|
||||||
CommandConfirmation, EventFilter, OperatorCommand, OperatorScreen, OperatorSnapshot,
|
CommandConfirmation, EventFilter, OperatorCommand, OperatorScreen, OperatorSnapshot,
|
||||||
OperatorTui, ReconnectingTcpTransport, TuiAction, TuiError, TuiInput, TuiRenderOptions,
|
OperatorTui, PreferenceField, PreferencesPanel, ReconnectingTcpTransport, TuiAction, TuiError,
|
||||||
TuiTransport,
|
TuiInput, TuiRenderOptions, TuiTransport,
|
||||||
};
|
};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage,
|
BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage,
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ impl LlmClient {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|part| matches!(part, ContentPart::Image { .. }))
|
.any(|part| matches!(part, ContentPart::Image { .. }))
|
||||||
});
|
});
|
||||||
let body = request_body(messages, tools)?;
|
let body = request_body(messages, tools, self.connection.model.as_deref())?;
|
||||||
if body.len() > self.limits.max_prompt_bytes {
|
if body.len() > self.limits.max_prompt_bytes {
|
||||||
return Err(LlmError::PromptTooLarge);
|
return Err(LlmError::PromptTooLarge);
|
||||||
}
|
}
|
||||||
@@ -567,6 +567,7 @@ async fn read_bounded(
|
|||||||
fn request_body(
|
fn request_body(
|
||||||
messages: &[CompletionMessage],
|
messages: &[CompletionMessage],
|
||||||
tools: &[ToolDefinition],
|
tools: &[ToolDefinition],
|
||||||
|
model: Option<&str>,
|
||||||
) -> Result<Vec<u8>, LlmError> {
|
) -> Result<Vec<u8>, LlmError> {
|
||||||
let messages = messages.iter().map(message_wire_value).collect::<Vec<_>>();
|
let messages = messages.iter().map(message_wire_value).collect::<Vec<_>>();
|
||||||
let tools = tools
|
let tools = tools
|
||||||
@@ -582,8 +583,11 @@ fn request_body(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
serde_json::to_vec(&json!({"messages":messages, "tools":tools}))
|
let mut request = json!({"messages":messages, "tools":tools});
|
||||||
.map_err(|_| LlmError::MalformedJson)
|
if let Some(model) = model {
|
||||||
|
request["model"] = Value::String(model.to_owned());
|
||||||
|
}
|
||||||
|
serde_json::to_vec(&request).map_err(|_| LlmError::MalformedJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn message_wire_value(message: &CompletionMessage) -> Value {
|
fn message_wire_value(message: &CompletionMessage) -> Value {
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ enum Operation {
|
|||||||
TuiClient,
|
TuiClient,
|
||||||
PrintPaths,
|
PrintPaths,
|
||||||
Acceptance,
|
Acceptance,
|
||||||
CheckLiveOptIns,
|
Preferences,
|
||||||
|
ImportEnv,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -45,6 +46,7 @@ struct Options {
|
|||||||
config: Option<PathBuf>,
|
config: Option<PathBuf>,
|
||||||
operation: Operation,
|
operation: Operation,
|
||||||
evidence: Option<PathBuf>,
|
evidence: Option<PathBuf>,
|
||||||
|
import_env: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn options() -> Result<Option<Options>, CliError> {
|
fn options() -> Result<Option<Options>, CliError> {
|
||||||
@@ -60,7 +62,7 @@ fn options() -> Result<Option<Options>, CliError> {
|
|||||||
}
|
}
|
||||||
if argument == "--help" || argument == "-h" {
|
if argument == "--help" || argument == "-h" {
|
||||||
println!(
|
println!(
|
||||||
"metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths | --acceptance-evidence PATH | --check-live-opt-ins]\n\
|
"metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --preferences | --import-env PATH | --print-paths | --acceptance-evidence PATH]\n\
|
||||||
Configuration precedence: defaults < JSON < secret files < environment.\n\
|
Configuration precedence: defaults < JSON < secret files < environment.\n\
|
||||||
With no --config, the platform default is used when it exists."
|
With no --config, the platform default is used when it exists."
|
||||||
);
|
);
|
||||||
@@ -86,8 +88,15 @@ fn options() -> Result<Option<Options>, CliError> {
|
|||||||
.ok_or_else(|| CliError("--acceptance-evidence requires a new path".into()))?;
|
.ok_or_else(|| CliError("--acceptance-evidence requires a new path".into()))?;
|
||||||
count += 1;
|
count += 1;
|
||||||
result.evidence = Some(PathBuf::from(path));
|
result.evidence = Some(PathBuf::from(path));
|
||||||
} else if argument == "--check-live-opt-ins" {
|
} else if argument == "--preferences" {
|
||||||
set_operation(&mut result, Operation::CheckLiveOptIns)?;
|
set_operation(&mut result, Operation::Preferences)?;
|
||||||
|
} else if argument == "--import-env" {
|
||||||
|
set_operation(&mut result, Operation::ImportEnv)?;
|
||||||
|
let path = arguments
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| CliError("--import-env requires a .env path".into()))?;
|
||||||
|
count += 1;
|
||||||
|
result.import_env = Some(PathBuf::from(path));
|
||||||
} else if argument == "--config" {
|
} else if argument == "--config" {
|
||||||
let path = arguments
|
let path = arguments
|
||||||
.next()
|
.next()
|
||||||
@@ -140,28 +149,31 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if options.operation == Operation::CheckLiveOptIns {
|
let mut loader = ConfigLoader::new();
|
||||||
let opt_ins =
|
let default_config_path = platform_paths.config_file;
|
||||||
metacrate_grid_agent::LiveGridOptIns::from_environment(|name| std::env::var(name).ok())
|
let config_path = options.config.or_else(|| {
|
||||||
.validate()?;
|
default_config_path
|
||||||
|
.is_file()
|
||||||
|
.then_some(default_config_path.clone())
|
||||||
|
});
|
||||||
|
let preferences_path = config_path.clone().unwrap_or(default_config_path);
|
||||||
|
if options.operation == Operation::Preferences {
|
||||||
|
metacrate_grid_agent::tui::run_preferences_terminal(preferences_path, !color_disabled())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if options.operation == Operation::ImportEnv {
|
||||||
|
let source = options
|
||||||
|
.import_env
|
||||||
|
.ok_or_else(|| CliError("legacy .env path is required".into()))?;
|
||||||
|
let mut preferences = metacrate_grid_agent::AgentPreferences::load(&preferences_path)?;
|
||||||
|
preferences.import_dotenv(source)?;
|
||||||
|
preferences.save(&preferences_path)?;
|
||||||
println!(
|
println!(
|
||||||
"live opt-ins: login={} chat_im={} script={} landmarks={} build={} visual={}",
|
"imported grid and AI settings into {}",
|
||||||
opt_ins.login,
|
preferences_path.display()
|
||||||
opt_ins.chat_and_im,
|
|
||||||
opt_ins.script_delivery,
|
|
||||||
opt_ins.landmarks_and_roaming,
|
|
||||||
opt_ins.reversible_build,
|
|
||||||
opt_ins.visual_capture
|
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut loader = ConfigLoader::new();
|
|
||||||
let config_path = options.config.or_else(|| {
|
|
||||||
platform_paths
|
|
||||||
.config_file
|
|
||||||
.is_file()
|
|
||||||
.then_some(platform_paths.config_file)
|
|
||||||
});
|
|
||||||
if let Some(path) = config_path {
|
if let Some(path) = config_path {
|
||||||
loader = loader.with_file(path);
|
loader = loader.with_file(path);
|
||||||
}
|
}
|
||||||
@@ -181,7 +193,11 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
token.clone(),
|
token.clone(),
|
||||||
config.control.limits,
|
config.control.limits,
|
||||||
);
|
);
|
||||||
metacrate_grid_agent::tui::run_terminal(std::sync::Arc::new(client), !color_disabled())
|
metacrate_grid_agent::tui::run_terminal_with_preferences(
|
||||||
|
std::sync::Arc::new(client),
|
||||||
|
!color_disabled(),
|
||||||
|
Some(preferences_path),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -191,6 +207,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
config,
|
config,
|
||||||
options.operation == Operation::RunOnce,
|
options.operation == Operation::RunOnce,
|
||||||
options.operation == Operation::Tui,
|
options.operation == Operation::Tui,
|
||||||
|
preferences_path,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
#[cfg(not(feature = "live-grid"))]
|
#[cfg(not(feature = "live-grid"))]
|
||||||
@@ -252,6 +269,7 @@ async fn run_live(
|
|||||||
config: metacrate_grid_agent::AgentConfig,
|
config: metacrate_grid_agent::AgentConfig,
|
||||||
run_once: bool,
|
run_once: bool,
|
||||||
tui: bool,
|
tui: bool,
|
||||||
|
preferences_path: PathBuf,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
use metacrate_grid_agent::{
|
use metacrate_grid_agent::{
|
||||||
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
|
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
|
||||||
@@ -402,10 +420,13 @@ async fn run_live(
|
|||||||
let client = integrated_client.ok_or_else(|| {
|
let client = integrated_client.ok_or_else(|| {
|
||||||
CliError("--tui requires integrated mode; use --tui-client for split mode".into())
|
CliError("--tui requires integrated mode; use --tui-client for split mode".into())
|
||||||
})?;
|
})?;
|
||||||
Some(tokio::spawn(metacrate_grid_agent::tui::run_terminal(
|
Some(tokio::spawn(
|
||||||
|
metacrate_grid_agent::tui::run_terminal_with_preferences(
|
||||||
Arc::new(client),
|
Arc::new(client),
|
||||||
!color_disabled(),
|
!color_disabled(),
|
||||||
)))
|
Some(preferences_path),
|
||||||
|
),
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ use crate::control_plane::{
|
|||||||
SessionMetadataView, TcpControlClient,
|
SessionMetadataView, TcpControlClient,
|
||||||
};
|
};
|
||||||
use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent};
|
use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent};
|
||||||
use crate::{ControlLimits, SecretString};
|
use crate::{AgentPreferences, ControlLimits, OperatingMode, PreferencesSummary, SecretString};
|
||||||
use crossterm::{cursor, event, execute, queue, style, terminal};
|
use crossterm::{cursor, event, execute, queue, style, terminal};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -39,10 +40,11 @@ pub enum OperatorScreen {
|
|||||||
Health,
|
Health,
|
||||||
Errors,
|
Errors,
|
||||||
Diagnostics,
|
Diagnostics,
|
||||||
|
Preferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OperatorScreen {
|
impl OperatorScreen {
|
||||||
const ALL: [Self; 9] = [
|
const ALL: [Self; 10] = [
|
||||||
Self::Overview,
|
Self::Overview,
|
||||||
Self::Sessions,
|
Self::Sessions,
|
||||||
Self::QueuesAndBudgets,
|
Self::QueuesAndBudgets,
|
||||||
@@ -52,6 +54,7 @@ impl OperatorScreen {
|
|||||||
Self::Health,
|
Self::Health,
|
||||||
Self::Errors,
|
Self::Errors,
|
||||||
Self::Diagnostics,
|
Self::Diagnostics,
|
||||||
|
Self::Preferences,
|
||||||
];
|
];
|
||||||
|
|
||||||
const fn title(self) -> &'static str {
|
const fn title(self) -> &'static str {
|
||||||
@@ -65,10 +68,246 @@ impl OperatorScreen {
|
|||||||
Self::Health => "Health & metrics",
|
Self::Health => "Health & metrics",
|
||||||
Self::Errors => "Recent errors",
|
Self::Errors => "Recent errors",
|
||||||
Self::Diagnostics => "Diagnostics",
|
Self::Diagnostics => "Diagnostics",
|
||||||
|
Self::Preferences => "Preferences",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PreferenceField {
|
||||||
|
Mode,
|
||||||
|
LlmEndpoint,
|
||||||
|
LlmApiKey,
|
||||||
|
LlmModel,
|
||||||
|
GridLoginUrl,
|
||||||
|
GridAvatarName,
|
||||||
|
GridPassword,
|
||||||
|
PrivilegedUsers,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreferenceField {
|
||||||
|
const ALL: [Self; 8] = [
|
||||||
|
Self::Mode,
|
||||||
|
Self::LlmEndpoint,
|
||||||
|
Self::LlmApiKey,
|
||||||
|
Self::LlmModel,
|
||||||
|
Self::GridLoginUrl,
|
||||||
|
Self::GridAvatarName,
|
||||||
|
Self::GridPassword,
|
||||||
|
Self::PrivilegedUsers,
|
||||||
|
];
|
||||||
|
|
||||||
|
const fn secret(self) -> bool {
|
||||||
|
matches!(self, Self::LlmApiKey | Self::GridPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PreferencesPanel {
|
||||||
|
path: PathBuf,
|
||||||
|
preferences: AgentPreferences,
|
||||||
|
selected: usize,
|
||||||
|
editing: bool,
|
||||||
|
buffer: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for PreferencesPanel {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("PreferencesPanel")
|
||||||
|
.field("path", &self.path)
|
||||||
|
.field("selected", &self.selected)
|
||||||
|
.field("editing", &self.editing)
|
||||||
|
.field("buffer_bytes", &self.buffer.len())
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreferencesPanel {
|
||||||
|
pub fn open(path: PathBuf) -> Result<Self, TuiError> {
|
||||||
|
let preferences =
|
||||||
|
AgentPreferences::load(&path).map_err(|error| TuiError(error.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
preferences,
|
||||||
|
selected: 0,
|
||||||
|
editing: false,
|
||||||
|
buffer: String::new(),
|
||||||
|
status: "ready".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn summary(&self) -> PreferencesSummary {
|
||||||
|
self.preferences.summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn selected_field(&self) -> PreferenceField {
|
||||||
|
PreferenceField::ALL[self.selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_field(&mut self) {
|
||||||
|
self.selected = (self.selected + 1) % PreferenceField::ALL.len();
|
||||||
|
}
|
||||||
|
pub fn previous_field(&mut self) {
|
||||||
|
self.selected =
|
||||||
|
(self.selected + PreferenceField::ALL.len() - 1) % PreferenceField::ALL.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin_edit(&mut self) {
|
||||||
|
self.editing = true;
|
||||||
|
self.buffer.clear();
|
||||||
|
self.status = if self.selected_field().secret() {
|
||||||
|
"editing secret (hidden)".into()
|
||||||
|
} else {
|
||||||
|
"editing".into()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, value: char) {
|
||||||
|
if self.editing && !value.is_control() && self.buffer.len() < 16 * 1024 {
|
||||||
|
self.buffer.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backspace(&mut self) {
|
||||||
|
if self.editing {
|
||||||
|
self.buffer.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_edit(&mut self) {
|
||||||
|
self.editing = false;
|
||||||
|
self.buffer.clear();
|
||||||
|
self.status = "edit cancelled".into();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_edit(&mut self) -> Result<(), TuiError> {
|
||||||
|
if !self.editing {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let value = std::mem::take(&mut self.buffer);
|
||||||
|
let summary = self.preferences.summary();
|
||||||
|
match self.selected_field() {
|
||||||
|
PreferenceField::Mode => {
|
||||||
|
let mode = match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"offline" => OperatingMode::OfflineFake,
|
||||||
|
"integrated" => OperatingMode::Integrated,
|
||||||
|
"split" => OperatingMode::SplitService,
|
||||||
|
_ => {
|
||||||
|
return Err(TuiError(
|
||||||
|
"mode must be offline, integrated, or split".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.preferences.set_mode(mode);
|
||||||
|
}
|
||||||
|
PreferenceField::LlmEndpoint => self.preferences.set_llm(value, None),
|
||||||
|
PreferenceField::LlmApiKey => self
|
||||||
|
.preferences
|
||||||
|
.set_llm(summary.llm_endpoint.unwrap_or_default(), Some(value)),
|
||||||
|
PreferenceField::LlmModel => self.preferences.set_llm_model(value),
|
||||||
|
PreferenceField::GridLoginUrl => {
|
||||||
|
self.preferences.set_grid(
|
||||||
|
value,
|
||||||
|
summary.grid_avatar_name.unwrap_or_default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PreferenceField::GridAvatarName => {
|
||||||
|
self.preferences
|
||||||
|
.set_grid(summary.grid_login_url.unwrap_or_default(), value, None);
|
||||||
|
}
|
||||||
|
PreferenceField::GridPassword => self.preferences.set_grid(
|
||||||
|
summary.grid_login_url.unwrap_or_default(),
|
||||||
|
summary.grid_avatar_name.unwrap_or_default(),
|
||||||
|
Some(value),
|
||||||
|
),
|
||||||
|
PreferenceField::PrivilegedUsers => self.preferences.set_privileged_users(
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|item| !item.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
self.editing = false;
|
||||||
|
self.status = "changed; press w to save".into();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&mut self) -> Result<(), TuiError> {
|
||||||
|
self.preferences
|
||||||
|
.save(&self.path)
|
||||||
|
.map_err(|error| TuiError(error.to_string()))?;
|
||||||
|
self.status = "saved; restart service to apply connection changes".into();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, out: &mut Vec<String>) {
|
||||||
|
let summary = self.summary();
|
||||||
|
out.push(format!(
|
||||||
|
"file={} mode={:?}",
|
||||||
|
self.path.display(),
|
||||||
|
summary.mode
|
||||||
|
));
|
||||||
|
let values = [
|
||||||
|
format!("mode: {:?}", summary.mode),
|
||||||
|
format!(
|
||||||
|
"LLM endpoint: {}",
|
||||||
|
summary.llm_endpoint.as_deref().unwrap_or("<unset>")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"LLM API key: {}",
|
||||||
|
configured(summary.llm_api_key_configured)
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"LLM model: {}",
|
||||||
|
summary.llm_model.as_deref().unwrap_or("<endpoint default>")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"grid login URL: {}",
|
||||||
|
summary.grid_login_url.as_deref().unwrap_or("<unset>")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"grid avatar: {}",
|
||||||
|
summary.grid_avatar_name.as_deref().unwrap_or("<unset>")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"grid password: {}",
|
||||||
|
configured(summary.grid_password_configured)
|
||||||
|
),
|
||||||
|
format!("privileged users: {}", summary.privileged_users.join(", ")),
|
||||||
|
];
|
||||||
|
for (index, value) in values.into_iter().enumerate() {
|
||||||
|
out.push(format!(
|
||||||
|
"{} {value}",
|
||||||
|
if index == self.selected { ">" } else { " " }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.editing {
|
||||||
|
out.push(format!(
|
||||||
|
"input: {}",
|
||||||
|
if self.selected_field().secret() {
|
||||||
|
"•".repeat(self.buffer.chars().count())
|
||||||
|
} else {
|
||||||
|
self.buffer.clone()
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push(format!(
|
||||||
|
"{} | [↑/↓] field [Enter] edit/commit [Esc] cancel [w] save",
|
||||||
|
self.status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured(value: bool) -> &'static str {
|
||||||
|
if value { "[configured]" } else { "[unset]" }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
pub struct EventFilter {
|
pub struct EventFilter {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
@@ -227,6 +466,13 @@ pub enum TuiInput {
|
|||||||
Confirm,
|
Confirm,
|
||||||
Reject,
|
Reject,
|
||||||
Quit,
|
Quit,
|
||||||
|
PreferenceNext,
|
||||||
|
PreferencePrevious,
|
||||||
|
PreferenceEditOrCommit,
|
||||||
|
PreferenceCharacter(char),
|
||||||
|
PreferenceBackspace,
|
||||||
|
PreferenceSave,
|
||||||
|
PreferenceCancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -364,6 +610,7 @@ pub struct OperatorTui {
|
|||||||
scroll: usize,
|
scroll: usize,
|
||||||
status: String,
|
status: String,
|
||||||
errors: VecDeque<String>,
|
errors: VecDeque<String>,
|
||||||
|
preferences: Option<PreferencesPanel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OperatorTui {
|
impl Default for OperatorTui {
|
||||||
@@ -377,11 +624,19 @@ impl Default for OperatorTui {
|
|||||||
scroll: 0,
|
scroll: 0,
|
||||||
status: "connecting".into(),
|
status: "connecting".into(),
|
||||||
errors: VecDeque::with_capacity(MAX_ERRORS),
|
errors: VecDeque::with_capacity(MAX_ERRORS),
|
||||||
|
preferences: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OperatorTui {
|
impl OperatorTui {
|
||||||
|
pub fn with_preferences(path: PathBuf) -> Result<Self, TuiError> {
|
||||||
|
Ok(Self {
|
||||||
|
preferences: Some(PreferencesPanel::open(path)?),
|
||||||
|
..Self::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn command_shortcut(&self, key: char) -> Option<OperatorCommand> {
|
pub fn command_shortcut(&self, key: char) -> Option<OperatorCommand> {
|
||||||
match key {
|
match key {
|
||||||
@@ -456,6 +711,56 @@ impl OperatorTui {
|
|||||||
}
|
}
|
||||||
TuiInput::Refresh => TuiAction::Refresh,
|
TuiInput::Refresh => TuiAction::Refresh,
|
||||||
TuiInput::Quit => TuiAction::Exit,
|
TuiInput::Quit => TuiAction::Exit,
|
||||||
|
TuiInput::PreferenceNext => {
|
||||||
|
if let Some(panel) = &mut self.preferences {
|
||||||
|
panel.next_field();
|
||||||
|
}
|
||||||
|
TuiAction::None
|
||||||
|
}
|
||||||
|
TuiInput::PreferencePrevious => {
|
||||||
|
if let Some(panel) = &mut self.preferences {
|
||||||
|
panel.previous_field();
|
||||||
|
}
|
||||||
|
TuiAction::None
|
||||||
|
}
|
||||||
|
TuiInput::PreferenceEditOrCommit => {
|
||||||
|
if let Some(panel) = &mut self.preferences {
|
||||||
|
if panel.editing {
|
||||||
|
if let Err(error) = panel.commit_edit() {
|
||||||
|
self.record_error(error.to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panel.begin_edit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TuiAction::None
|
||||||
|
}
|
||||||
|
TuiInput::PreferenceCharacter(value) => {
|
||||||
|
if let Some(panel) = &mut self.preferences {
|
||||||
|
panel.push(value);
|
||||||
|
}
|
||||||
|
TuiAction::None
|
||||||
|
}
|
||||||
|
TuiInput::PreferenceBackspace => {
|
||||||
|
if let Some(panel) = &mut self.preferences {
|
||||||
|
panel.backspace();
|
||||||
|
}
|
||||||
|
TuiAction::None
|
||||||
|
}
|
||||||
|
TuiInput::PreferenceSave => {
|
||||||
|
if let Some(panel) = &mut self.preferences
|
||||||
|
&& let Err(error) = panel.save()
|
||||||
|
{
|
||||||
|
self.record_error(error.to_string());
|
||||||
|
}
|
||||||
|
TuiAction::None
|
||||||
|
}
|
||||||
|
TuiInput::PreferenceCancel => {
|
||||||
|
if let Some(panel) = &mut self.preferences {
|
||||||
|
panel.cancel_edit();
|
||||||
|
}
|
||||||
|
TuiAction::None
|
||||||
|
}
|
||||||
TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => {
|
TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => {
|
||||||
TuiAction::Execute(command)
|
TuiAction::Execute(command)
|
||||||
}
|
}
|
||||||
@@ -587,6 +892,7 @@ impl OperatorTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn render(&self, options: TuiRenderOptions) -> String {
|
pub fn render(&self, options: TuiRenderOptions) -> String {
|
||||||
let width = usize::from(options.width.max(20));
|
let width = usize::from(options.width.max(20));
|
||||||
let height = usize::from(options.height.max(5));
|
let height = usize::from(options.height.max(5));
|
||||||
@@ -673,6 +979,13 @@ impl OperatorTui {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
OperatorScreen::Preferences => {
|
||||||
|
if let Some(panel) = &self.preferences {
|
||||||
|
panel.render(&mut lines);
|
||||||
|
} else {
|
||||||
|
lines.push("preferences unavailable: no local config path".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let body_height = height.saturating_sub(1);
|
let body_height = height.saturating_sub(1);
|
||||||
lines
|
lines
|
||||||
@@ -796,9 +1109,77 @@ impl Drop for TerminalGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the local configuration preferences panel without starting or
|
||||||
|
/// connecting to the agent service. Secret fields are rendered as bullets.
|
||||||
|
pub fn run_preferences_terminal(path: PathBuf, _color: bool) -> Result<(), TuiError> {
|
||||||
|
let _guard = TerminalGuard::enter().map_err(|error| TuiError(error.to_string()))?;
|
||||||
|
let mut panel = PreferencesPanel::open(path)?;
|
||||||
|
loop {
|
||||||
|
let (width, height) = terminal::size().unwrap_or((80, 24));
|
||||||
|
let mut lines = vec!["MetaCrate agent | Preferences".to_owned()];
|
||||||
|
panel.render(&mut lines);
|
||||||
|
let text = lines
|
||||||
|
.into_iter()
|
||||||
|
.take(usize::from(height.max(5)))
|
||||||
|
.map(|line| truncate_width(&line, usize::from(width.max(20))))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
queue!(
|
||||||
|
stdout,
|
||||||
|
cursor::MoveTo(0, 0),
|
||||||
|
terminal::Clear(terminal::ClearType::All),
|
||||||
|
style::Print(text)
|
||||||
|
)
|
||||||
|
.map_err(|error| TuiError(error.to_string()))?;
|
||||||
|
stdout
|
||||||
|
.flush()
|
||||||
|
.map_err(|error| TuiError(error.to_string()))?;
|
||||||
|
let event::Event::Key(key) = event::read().map_err(|error| TuiError(error.to_string()))?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if key.kind != event::KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if panel.editing {
|
||||||
|
match key.code {
|
||||||
|
event::KeyCode::Enter => panel.commit_edit()?,
|
||||||
|
event::KeyCode::Backspace => panel.backspace(),
|
||||||
|
event::KeyCode::Esc => panel.cancel_edit(),
|
||||||
|
event::KeyCode::Char(value) => panel.push(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match key.code {
|
||||||
|
event::KeyCode::Up => panel.previous_field(),
|
||||||
|
event::KeyCode::Down => panel.next_field(),
|
||||||
|
event::KeyCode::Enter => panel.begin_edit(),
|
||||||
|
event::KeyCode::Char('w') => panel.save()?,
|
||||||
|
event::KeyCode::Char('q') | event::KeyCode::Esc => return Ok(()),
|
||||||
|
event::KeyCode::Char('c')
|
||||||
|
if key.modifiers.contains(event::KeyModifiers::CONTROL) =>
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs an event-driven keyboard UI. Terminal state is restored on normal
|
/// Runs an event-driven keyboard UI. Terminal state is restored on normal
|
||||||
/// return, errors, Ctrl-C, and unwinding because restoration is guard-owned.
|
/// return, errors, Ctrl-C, and unwinding because restoration is guard-owned.
|
||||||
pub async fn run_terminal(client: Arc<dyn TuiTransport>, color: bool) -> Result<(), TuiError> {
|
pub async fn run_terminal(client: Arc<dyn TuiTransport>, color: bool) -> Result<(), TuiError> {
|
||||||
|
run_terminal_with_preferences(client, color, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
pub async fn run_terminal_with_preferences(
|
||||||
|
client: Arc<dyn TuiTransport>,
|
||||||
|
color: bool,
|
||||||
|
preferences_path: Option<PathBuf>,
|
||||||
|
) -> Result<(), TuiError> {
|
||||||
let _guard = TerminalGuard::enter().map_err(|e| TuiError(e.to_string()))?;
|
let _guard = TerminalGuard::enter().map_err(|e| TuiError(e.to_string()))?;
|
||||||
let (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64);
|
let (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -808,7 +1189,11 @@ pub async fn run_terminal(client: Arc<dyn TuiTransport>, color: bool) -> Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let mut app = OperatorTui::default();
|
let mut app = if let Some(path) = preferences_path {
|
||||||
|
OperatorTui::with_preferences(path)?
|
||||||
|
} else {
|
||||||
|
OperatorTui::default()
|
||||||
|
};
|
||||||
app.refresh(client.as_ref()).await?;
|
app.refresh(client.as_ref()).await?;
|
||||||
let mut refresh = tokio::time::interval(Duration::from_secs(1));
|
let mut refresh = tokio::time::interval(Duration::from_secs(1));
|
||||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
@@ -845,6 +1230,35 @@ pub async fn run_terminal(client: Arc<dyn TuiTransport>, color: bool) -> Result<
|
|||||||
};
|
};
|
||||||
let input = match value {
|
let input = match value {
|
||||||
event::Event::Resize(_, _) => TuiInput::Refresh,
|
event::Event::Resize(_, _) => TuiInput::Refresh,
|
||||||
|
event::Event::Key(key)
|
||||||
|
if key.kind == event::KeyEventKind::Press
|
||||||
|
&& app.screen == OperatorScreen::Preferences
|
||||||
|
&& app.preferences.as_ref().is_some_and(|panel| panel.editing) =>
|
||||||
|
{
|
||||||
|
match key.code {
|
||||||
|
event::KeyCode::Enter => TuiInput::PreferenceEditOrCommit,
|
||||||
|
event::KeyCode::Backspace => TuiInput::PreferenceBackspace,
|
||||||
|
event::KeyCode::Esc => TuiInput::PreferenceCancel,
|
||||||
|
event::KeyCode::Char(value) => TuiInput::PreferenceCharacter(value),
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event::Event::Key(key)
|
||||||
|
if key.kind == event::KeyEventKind::Press
|
||||||
|
&& app.screen == OperatorScreen::Preferences =>
|
||||||
|
{
|
||||||
|
match key.code {
|
||||||
|
event::KeyCode::Up => TuiInput::PreferencePrevious,
|
||||||
|
event::KeyCode::Down => TuiInput::PreferenceNext,
|
||||||
|
event::KeyCode::Enter => TuiInput::PreferenceEditOrCommit,
|
||||||
|
event::KeyCode::Char('w') => TuiInput::PreferenceSave,
|
||||||
|
event::KeyCode::Esc => TuiInput::PreferenceCancel,
|
||||||
|
event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen,
|
||||||
|
event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen,
|
||||||
|
event::KeyCode::Char('q') => TuiInput::Quit,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
event::Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code {
|
event::Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code {
|
||||||
event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen,
|
event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen,
|
||||||
event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen,
|
event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen,
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() {
|
|||||||
OperatorScreen::Health,
|
OperatorScreen::Health,
|
||||||
OperatorScreen::Errors,
|
OperatorScreen::Errors,
|
||||||
OperatorScreen::Diagnostics,
|
OperatorScreen::Diagnostics,
|
||||||
|
OperatorScreen::Preferences,
|
||||||
] {
|
] {
|
||||||
while app.screen != screen {
|
while app.screen != screen {
|
||||||
let _ = app.reduce(TuiInput::NextScreen);
|
let _ = app.reduce(TuiInput::NextScreen);
|
||||||
@@ -153,7 +154,7 @@ fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() {
|
|||||||
fn navigation_wraps_and_every_management_command_has_risk_confirmation() {
|
fn navigation_wraps_and_every_management_command_has_risk_confirmation() {
|
||||||
let mut app = OperatorTui::default();
|
let mut app = OperatorTui::default();
|
||||||
assert_eq!(app.reduce(TuiInput::PreviousScreen), TuiAction::None);
|
assert_eq!(app.reduce(TuiInput::PreviousScreen), TuiAction::None);
|
||||||
assert_eq!(app.screen, OperatorScreen::Diagnostics);
|
assert_eq!(app.screen, OperatorScreen::Preferences);
|
||||||
let commands = [
|
let commands = [
|
||||||
OperatorCommand::Pause,
|
OperatorCommand::Pause,
|
||||||
OperatorCommand::Resume,
|
OperatorCommand::Resume,
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
use metacrate_grid_agent::{
|
use metacrate_grid_agent::{
|
||||||
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets,
|
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets,
|
||||||
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns,
|
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, run_deterministic_acceptance,
|
||||||
run_deterministic_acceptance,
|
|
||||||
};
|
};
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -62,23 +60,6 @@ async fn deterministic_gate_writes_ordered_redacted_evidence_and_drains() {
|
|||||||
fs::remove_file(path).unwrap();
|
fs::remove_file(path).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_actions_require_exact_independent_confirmations() {
|
|
||||||
let mut values = BTreeMap::new();
|
|
||||||
values.insert("METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM");
|
|
||||||
let action_without_login =
|
|
||||||
LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string));
|
|
||||||
assert!(action_without_login.validate().is_err());
|
|
||||||
values.insert("METACRATE_AGENT_LIVE_LOGIN", "LOGIN");
|
|
||||||
values.insert("METACRATE_AGENT_LIVE_BUILD", "wrong");
|
|
||||||
let confirmed =
|
|
||||||
LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string))
|
|
||||||
.validate()
|
|
||||||
.unwrap();
|
|
||||||
assert!(confirmed.login && confirmed.chat_and_im);
|
|
||||||
assert!(!confirmed.reversible_build);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn evidence_writer_rejects_secret_markers_and_schema_is_committed() {
|
fn evidence_writer_rejects_secret_markers_and_schema_is_committed() {
|
||||||
let path = temporary("unsafe.jsonl");
|
let path = temporary("unsafe.jsonl");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::collections::BTreeSet;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const ALLOWED_DEPENDENCIES: [&str; 17] = [
|
const ALLOWED_DEPENDENCIES: [&str; 18] = [
|
||||||
"base64",
|
"base64",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"libremetaverse",
|
"libremetaverse",
|
||||||
@@ -15,6 +15,7 @@ const ALLOWED_DEPENDENCIES: [&str; 17] = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml_ng",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use metacrate_grid_agent::{CONFIG_SCHEMA_VERSION, ConfigLoader};
|
use metacrate_grid_agent::{CONFIG_SCHEMA_VERSION, ConfigLoader};
|
||||||
use serde_json::Value;
|
use serde_yaml_ng::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -15,19 +15,22 @@ fn workspace() -> PathBuf {
|
|||||||
fn examples_are_versioned_placeholder_only_and_offline_validation_has_no_io_peer() {
|
fn examples_are_versioned_placeholder_only_and_offline_validation_has_no_io_peer() {
|
||||||
let root = workspace();
|
let root = workspace();
|
||||||
for name in [
|
for name in [
|
||||||
"grid-agent.example.json",
|
"grid-agent.example.yml",
|
||||||
"grid-agent.integrated.example.json",
|
"grid-agent.integrated.example.yml",
|
||||||
"grid-agent.split.example.json",
|
"grid-agent.split.example.yml",
|
||||||
] {
|
] {
|
||||||
let bytes = fs::read(root.join("config").join(name)).expect("example");
|
let bytes = fs::read(root.join("config").join(name)).expect("example");
|
||||||
let value: Value = serde_json::from_slice(&bytes).expect("valid JSON");
|
let value: Value = serde_yaml_ng::from_slice(&bytes).expect("valid YAML");
|
||||||
assert_eq!(value["schema_version"], CONFIG_SCHEMA_VERSION);
|
assert_eq!(
|
||||||
|
value["schema_version"].as_u64(),
|
||||||
|
Some(u64::from(CONFIG_SCHEMA_VERSION))
|
||||||
|
);
|
||||||
let text = String::from_utf8(bytes).unwrap();
|
let text = String::from_utf8(bytes).unwrap();
|
||||||
for forbidden in ["Bearer ", "sk-", "password123", "SECRET_CANARY"] {
|
for forbidden in ["Bearer ", "sk-", "password123", "SECRET_CANARY"] {
|
||||||
assert!(!text.contains(forbidden), "{name} contains {forbidden}");
|
assert!(!text.contains(forbidden), "{name} contains {forbidden}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let offline = root.join("config/grid-agent.example.json");
|
let offline = root.join("config/grid-agent.example.yml");
|
||||||
let config = ConfigLoader::new().with_file(offline).load().unwrap();
|
let config = ConfigLoader::new().with_file(offline).load().unwrap();
|
||||||
assert!(config.grid.is_none());
|
assert!(config.grid.is_none());
|
||||||
}
|
}
|
||||||
@@ -46,10 +49,11 @@ fn service_and_installers_preserve_state_secrets_and_graceful_shutdown() {
|
|||||||
"ProtectSystem=strict",
|
"ProtectSystem=strict",
|
||||||
"NoNewPrivileges=true",
|
"NoNewPrivileges=true",
|
||||||
"ReadWritePaths=/var/lib/metacrate/grid-agent",
|
"ReadWritePaths=/var/lib/metacrate/grid-agent",
|
||||||
|
"/etc/metacrate/config.yml",
|
||||||
] {
|
] {
|
||||||
assert!(unit.contains(required), "unit lacks {required}");
|
assert!(unit.contains(required), "unit lacks {required}");
|
||||||
}
|
}
|
||||||
for forbidden in ["API_KEY=", "PASSWORD=", "TOKEN="] {
|
for forbidden in ["API_KEY=", "PASSWORD=", "TOKEN=", "EnvironmentFile="] {
|
||||||
assert!(!unit.contains(forbidden), "unit embeds {forbidden}");
|
assert!(!unit.contains(forbidden), "unit embeds {forbidden}");
|
||||||
}
|
}
|
||||||
let shell = fs::read_to_string(root.join("packaging/metacrate-grid-agent/install.sh")).unwrap();
|
let shell = fs::read_to_string(root.join("packaging/metacrate-grid-agent/install.sh")).unwrap();
|
||||||
|
|||||||
110
crates/metacrate-grid-agent/tests/preferences_config.rs
Normal file
110
crates/metacrate-grid-agent/tests/preferences_config.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use metacrate_grid_agent::{
|
||||||
|
AgentPreferences, ConfigLoader, MapEnvironment, OperatingMode, OperatorScreen, OperatorTui,
|
||||||
|
PreferencesPanel, TuiInput, TuiRenderOptions,
|
||||||
|
};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn temporary(name: &str) -> PathBuf {
|
||||||
|
let directory = std::env::temp_dir().join(format!(
|
||||||
|
"metacrate-preferences-{}-{name}",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&directory).unwrap();
|
||||||
|
directory
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn yaml_is_primary_and_preferences_preserve_unknown_settings() {
|
||||||
|
let directory = temporary("yaml");
|
||||||
|
let path = directory.join("config.yml");
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
r"schema_version: 1
|
||||||
|
integrated: true
|
||||||
|
llm:
|
||||||
|
endpoint_url: https://ai.invalid/v1/chat/completions
|
||||||
|
api_key: private-key
|
||||||
|
model: local-model
|
||||||
|
grid:
|
||||||
|
login_url: https://grid.invalid/login
|
||||||
|
avatar_name: Service Avatar
|
||||||
|
password: private-password
|
||||||
|
authorized_avatar_uuids:
|
||||||
|
- 00000000-0000-4000-8000-000000000001
|
||||||
|
behavior:
|
||||||
|
idle_interval_seconds: 321
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = ConfigLoader::new()
|
||||||
|
.with_file(&path)
|
||||||
|
.with_environment(MapEnvironment::default())
|
||||||
|
.load()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(config.mode, OperatingMode::Integrated);
|
||||||
|
assert_eq!(config.llm.model.as_deref(), Some("local-model"));
|
||||||
|
assert_eq!(config.behavior.idle_interval.as_secs(), 321);
|
||||||
|
|
||||||
|
let mut preferences = AgentPreferences::load(&path).unwrap();
|
||||||
|
preferences.set_llm_model("replacement-model".into());
|
||||||
|
preferences.save(&path).unwrap();
|
||||||
|
let saved = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(saved.contains("idle_interval_seconds: 321"));
|
||||||
|
assert!(saved.contains("model: replacement-model"));
|
||||||
|
assert!(!format!("{preferences:?}").contains("private-key"));
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_import_is_explicit_and_preferences_panel_never_renders_secrets() {
|
||||||
|
let directory = temporary("import");
|
||||||
|
let dotenv = directory.join("legacy.env");
|
||||||
|
let config = directory.join("config.yml");
|
||||||
|
fs::write(
|
||||||
|
&dotenv,
|
||||||
|
"OPENAPI_URL=https://ai.invalid/v1/chat/completions\nOPENAPI_KEY=very-secret-key\nOPENAPI_MODEL=test-model\nGRID_LOGIN_URL=https://grid.invalid/login\nGRID_USER=Test Avatar\nGRID_PASSWORD=very-secret-password\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut preferences = AgentPreferences::load(&config).unwrap();
|
||||||
|
preferences.import_dotenv(&dotenv).unwrap();
|
||||||
|
preferences.set_privileged_users(vec!["00000000-0000-4000-8000-000000000001".into()]);
|
||||||
|
preferences.save(&config).unwrap();
|
||||||
|
|
||||||
|
let panel = PreferencesPanel::open(config.clone()).unwrap();
|
||||||
|
let summary = panel.summary();
|
||||||
|
assert_eq!(summary.llm_model.as_deref(), Some("test-model"));
|
||||||
|
assert!(summary.llm_api_key_configured && summary.grid_password_configured);
|
||||||
|
assert_eq!(summary.privileged_users.len(), 1);
|
||||||
|
|
||||||
|
let mut tui = OperatorTui::with_preferences(config).unwrap();
|
||||||
|
tui.screen = OperatorScreen::Preferences;
|
||||||
|
let rendered = tui.render(TuiRenderOptions {
|
||||||
|
width: 120,
|
||||||
|
height: 30,
|
||||||
|
color: false,
|
||||||
|
});
|
||||||
|
assert!(rendered.contains("LLM API key: [configured]"));
|
||||||
|
assert!(rendered.contains("grid password: [configured]"));
|
||||||
|
assert!(!rendered.contains("very-secret"));
|
||||||
|
let _ = tui.reduce(TuiInput::PreferenceNext);
|
||||||
|
let _ = tui.reduce(TuiInput::PreferenceEditOrCommit);
|
||||||
|
for value in "https://replacement.invalid/v1/chat".chars() {
|
||||||
|
let _ = tui.reduce(TuiInput::PreferenceCharacter(value));
|
||||||
|
}
|
||||||
|
let _ = tui.reduce(TuiInput::PreferenceEditOrCommit);
|
||||||
|
let _ = tui.reduce(TuiInput::PreferenceSave);
|
||||||
|
fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ transport failures, visual fallback, and bounded-load cases.
|
|||||||
|
|
||||||
The evidence stages are `configuration_and_bounds`, `headless_startup`,
|
The evidence stages are `configuration_and_bounds`, `headless_startup`,
|
||||||
`control_conformance`, `chat_scheduling`, `clean_shutdown`, `maintenance_reconnect`, and
|
`control_conformance`, `chat_scheduling`, `clean_shutdown`, `maintenance_reconnect`, and
|
||||||
`policy_redaction_and_protocol_audit`. A successful run has six ordered
|
`policy_redaction_and_protocol_audit`. A successful run has seven ordered
|
||||||
`passed` records and leaves zero service tasks, grid sessions, or loopback
|
`passed` records and leaves zero service tasks, grid sessions, or loopback
|
||||||
sockets. Headless startup never creates a TUI. Integrated and split UI clients
|
sockets. Headless startup never creates a TUI. Integrated and split UI clients
|
||||||
exercise the same versioned control protocol, commands, event model, and
|
exercise the same versioned control protocol, commands, event model, and
|
||||||
@@ -38,9 +38,9 @@ graceful-shutdown target in their conformance tests.
|
|||||||
Reference deterministic run on 2026-08-18: all 137 library scenarios, 37
|
Reference deterministic run on 2026-08-18: all 137 library scenarios, 37
|
||||||
integration scenarios, and one compile-fail documentation case passed with all
|
integration scenarios, and one compile-fail documentation case passed with all
|
||||||
features; the seven-stage evidence command passed with zero leaked tasks,
|
features; the seven-stage evidence command passed with zero leaked tasks,
|
||||||
sessions, or sockets. The completed live-grid release binary was 28,857,688
|
sessions, or sockets. The completed live-grid release binary was 29,487,544
|
||||||
bytes with SHA-256
|
bytes with SHA-256
|
||||||
`b2c93a6acd6fa856669fdb4e08110d700218255f57ec2f7908977eeabea7c470`,
|
`1ccbd11baf69372d76767c419b59319447ecf088bc0ae83ce1640709f6507ad0`,
|
||||||
under the 40-MiB budget. The record identifies the package, pinned Rust toolchain,
|
under the 40-MiB budget. The record identifies the package, pinned Rust toolchain,
|
||||||
source revision when supplied through `METACRATE_SOURCE_COMMIT`, exact generic
|
source revision when supplied through `METACRATE_SOURCE_COMMIT`, exact generic
|
||||||
command, fake/live profile, grid type, endpoint capability profile, timestamp,
|
command, fake/live profile, grid type, endpoint capability profile, timestamp,
|
||||||
@@ -62,7 +62,7 @@ but small enough to expose deadlocks and unbounded ownership:
|
|||||||
| Ordered shutdown | 5,000 ms | Allows journal flush and task joins while remaining service-manager friendly. |
|
| Ordered shutdown | 5,000 ms | Allows journal flush and task joins while remaining service-manager friendly. |
|
||||||
| Steady/peak memory | 128/256 MiB | Includes bounded queues, conversations, observations, and one visual frame. |
|
| Steady/peak memory | 128/256 MiB | Includes bounded queues, conversations, observations, and one visual frame. |
|
||||||
| Any queue | 8,192 items | Matches the hard configuration ceiling; defaults are 32–512. |
|
| Any queue | 8,192 items | Matches the hard configuration ceiling; defaults are 32–512. |
|
||||||
| Release binary | 40 MiB | The completed live-grid release is 28,857,688 bytes. |
|
| Release binary | 40 MiB | The completed live-grid release is 29,487,544 bytes. |
|
||||||
| Journal retention | 1 GiB | Operator-configured segment and total limits remain mandatory. |
|
| Journal retention | 1 GiB | Operator-configured segment and total limits remain mandatory. |
|
||||||
|
|
||||||
Startup, control, reconnect, and shutdown are measured by monotonic time.
|
Startup, control, reconnect, and shutdown are measured by monotonic time.
|
||||||
@@ -76,29 +76,21 @@ because allocator and OS accounting are not comparable across platforms.
|
|||||||
|
|
||||||
## Live-grid matrix
|
## Live-grid matrix
|
||||||
|
|
||||||
Live validation is optional and requires a dedicated avatar, controlled land,
|
Live validation uses a dedicated avatar, controlled land, and an
|
||||||
and an operator-supplied OpenAI-compatible endpoint. Credentials alone grant no
|
operator-supplied OpenAI-compatible endpoint configured in the private
|
||||||
consent. Inspect exact confirmations without contacting either service:
|
platform `config.yml`. There are no action-specific environment switches.
|
||||||
|
Authorization follows the production policy model:
|
||||||
|
|
||||||
```sh
|
| Origin | Available behavior |
|
||||||
metacrate-grid-agent --check-live-opt-ins
|
|
||||||
```
|
|
||||||
|
|
||||||
Each capability has a separate exact-value environment opt-in:
|
|
||||||
|
|
||||||
| Capability | Variable and required literal |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Login/relogin | `METACRATE_AGENT_LIVE_LOGIN=LOGIN` |
|
| Everyone/public chat | Informational responses and explicitly public-safe tools. |
|
||||||
| Public mention and authorized/unprivileged IM | `METACRATE_AGENT_LIVE_CHAT_IM=CHAT-IM` |
|
| Ordinary IM | Private conversation without privileged mutation. |
|
||||||
| Controlled LSL delivery | `METACRATE_AGENT_LIVE_SCRIPT=SCRIPT` |
|
| UUID listed in `authorized_avatar_uuids` | Policy-gated privileged tools, with approval where required. |
|
||||||
| Landmark offer, teleport, and bounded roaming | `METACRATE_AGENT_LIVE_LANDMARKS=LANDMARKS` |
|
| Local operator/control role | Pause, approve/cancel, reconnect, configuration, and shutdown controls. |
|
||||||
| Reversible prim build and cleanup | `METACRATE_AGENT_LIVE_BUILD=BUILD-CLEANUP` |
|
|
||||||
| Synthetic visual capture and visual question | `METACRATE_AGENT_LIVE_VISUAL=VISUAL` |
|
|
||||||
|
|
||||||
Any action opt-in without the login opt-in fails closed. A misspelled value is
|
Landmarks, scripts, builds, cleanup, and visual questions are capabilities,
|
||||||
false. Store the grid password and endpoint key in restrictive `_FILE` inputs
|
not configuration modes. They remain governed by tool origin, authenticated
|
||||||
described by [the operations guide](grid-agent-operations.md), never in these
|
UUID, land/ownership checks, bounded resources, and approval policy.
|
||||||
variables or a command line.
|
|
||||||
|
|
||||||
For an authorized live run, start split mode so another terminal can reconnect
|
For an authorized live run, start split mode so another terminal can reconnect
|
||||||
the TUI without affecting the agent session. Record UTC start/end, commit,
|
the TUI without affecting the agent session. Record UTC start/end, commit,
|
||||||
@@ -113,15 +105,15 @@ hash. Do not record vendor presets or identifiers. Exercise, in order:
|
|||||||
exact rollover, facing/attention event, and perception queries.
|
exact rollover, facing/attention event, and perception queries.
|
||||||
3. Pause, cancel, approve, resume, and force reconnect from the control client;
|
3. Pause, cancel, approve, resume, and force reconnect from the control client;
|
||||||
disconnect/reconnect the TUI and verify the service remains headless-safe.
|
disconnect/reconnect the TUI and verify the service remains headless-safe.
|
||||||
4. With the script opt-in, deliver only to the controlled recipient and record
|
4. As a privileged user, deliver a script only to the controlled recipient and record
|
||||||
the returned inventory ID and permissions. Advanced mutation requires an
|
the returned inventory ID and permissions. Advanced mutation requires an
|
||||||
explicit approval and conservative script size/runtime limits.
|
explicit approval and conservative script size/runtime limits.
|
||||||
5. With the build opt-in, build only on controlled land, record transaction and
|
5. As a privileged user, build only on controlled land, record transaction and
|
||||||
object recovery IDs locally, verify no currency operation exists, and delete
|
object recovery IDs locally, verify no currency operation exists, and delete
|
||||||
every created prim through the ownership-checked cleanup path.
|
every created prim through the ownership-checked cleanup path.
|
||||||
6. With the landmark opt-in, accept a controlled offer, use a short bounded
|
6. Accept a controlled landmark offer, use a short bounded folder schedule,
|
||||||
folder schedule, teleport, then disable the schedule. With visual opt-in,
|
teleport, then disable the schedule. Capture the synthetic scene and ask
|
||||||
capture the synthetic scene and ask one visual question. Record the endpoint
|
one visual question. Record the endpoint
|
||||||
capability fallback if image input is rejected.
|
capability fallback if image input is rejected.
|
||||||
7. Gracefully stop. Confirm no pending approvals, scheduled jobs, inventory
|
7. Gracefully stop. Confirm no pending approvals, scheduled jobs, inventory
|
||||||
offers, owned test prims, tasks, sockets, or sessions. List any unavoidable
|
offers, owned test prims, tasks, sockets, or sessions. List any unavoidable
|
||||||
@@ -147,8 +139,7 @@ needs `x86_64-w64-mingw32-gcc` for the existing AWS-LC build.
|
|||||||
## Remaining limitations
|
## Remaining limitations
|
||||||
|
|
||||||
No public CI runner performs live actions, holds credentials, measures portable
|
No public CI runner performs live actions, holds credentials, measures portable
|
||||||
RSS, or proves a particular provider's image capability. Live evidence is only
|
RSS, or proves a particular endpoint's image capability. Live evidence is only
|
||||||
credible when an operator supplies all exact opt-ins and completes the matrix
|
credible when an operator completes the matrix on a dedicated account. The deterministic gate is therefore the required CI
|
||||||
on a dedicated account. The deterministic gate is therefore the required CI
|
|
||||||
acceptance record; a live report supplements it and must state any skipped
|
acceptance record; a live report supplements it and must state any skipped
|
||||||
capability, endpoint fallback, or manually recoverable artifact explicitly.
|
capability, endpoint fallback, or manually recoverable artifact explicitly.
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ cleanup, fencing old events and late LLM/tool results. See
|
|||||||
|
|
||||||
## Trust boundaries
|
## Trust boundaries
|
||||||
|
|
||||||
- JSON configuration and environment text are untrusted. Unknown fields,
|
- YAML configuration and legacy migration input are untrusted. Unknown fields,
|
||||||
oversized files, invalid booleans, conflicting modes, non-HTTP(S) URLs,
|
oversized files, invalid booleans, conflicting modes, non-HTTP(S) URLs,
|
||||||
URL fragments, malformed/noncanonical/nil UUIDs, wildcard authorization,
|
URL fragments, malformed/noncanonical/nil UUIDs, wildcard authorization,
|
||||||
multiline secrets, and unsafe limits fail before startup.
|
multiline secrets, and unsafe limits fail before startup.
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ grid or LLM:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --locked --release -p metacrate-grid-agent
|
cargo build --locked --release -p metacrate-grid-agent
|
||||||
target/release/metacrate-grid-agent --config config/grid-agent.example.json --check-config
|
target/release/metacrate-grid-agent --config config/grid-agent.example.yml --check-config
|
||||||
target/release/metacrate-grid-agent --config config/grid-agent.example.json --run-once
|
target/release/metacrate-grid-agent --config config/grid-agent.example.yml --run-once
|
||||||
```
|
```
|
||||||
|
|
||||||
`--check-config` parses files, resolves secret files/environment, validates all
|
`--check-config` parses YAML and any legacy referenced secret files, validates all
|
||||||
bounds and TLS files, then exits before constructing the grid or LLM clients.
|
bounds and TLS files, then exits before constructing the grid or LLM clients.
|
||||||
The example is fake/offline mode (`integrated=false`, `split=false`) and all
|
The example is fake/offline mode (`integrated=false`, `split=false`) and all
|
||||||
credential-looking values are placeholders.
|
credential-looking values are placeholders.
|
||||||
@@ -26,16 +26,16 @@ For live foreground or an embedded TUI, build intentionally:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --locked --release -p metacrate-grid-agent --features live-grid
|
cargo build --locked --release -p metacrate-grid-agent --features live-grid
|
||||||
target/release/metacrate-grid-agent --config /path/to/grid-agent.json
|
target/release/metacrate-grid-agent --config /path/to/config.yml
|
||||||
target/release/metacrate-grid-agent --config /path/to/grid-agent.json --tui
|
target/release/metacrate-grid-agent --config /path/to/config.yml --tui
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `integrated=true` for the foreground and embedded-TUI commands. Set
|
Set `integrated=true` for the foreground and embedded-TUI commands. Set
|
||||||
`split=true` for a headless service, then run the same binary as the TUI client:
|
`split=true` for a headless service, then run the same binary as the TUI client:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
metacrate-grid-agent --config /path/to/grid-agent.json
|
metacrate-grid-agent --config /path/to/config.yml
|
||||||
metacrate-grid-agent --config /path/to/grid-agent.json --tui-client
|
metacrate-grid-agent --config /path/to/config.yml --tui-client
|
||||||
```
|
```
|
||||||
|
|
||||||
The TUI client and service must use the same control address and operator token.
|
The TUI client and service must use the same control address and operator token.
|
||||||
@@ -46,12 +46,11 @@ the default file is used only when it already exists:
|
|||||||
|
|
||||||
| Platform | Configuration | Non-secret state |
|
| Platform | Configuration | Non-secret state |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Linux/Unix | `$XDG_CONFIG_HOME/metacrate/grid-agent.json`, otherwise `$HOME/.config/metacrate/grid-agent.json` | `$XDG_DATA_HOME/metacrate/grid-agent`, otherwise `$HOME/.local/share/metacrate/grid-agent` |
|
| Linux/Unix | `$XDG_CONFIG_HOME/metacrate/config.yml`, otherwise `$HOME/.config/metacrate/config.yml` | `$XDG_DATA_HOME/metacrate/grid-agent`, otherwise `$HOME/.local/share/metacrate/grid-agent` |
|
||||||
| macOS | `$HOME/Library/Application Support/MetaCrate/grid-agent.json` | `$HOME/Library/Application Support/MetaCrate/grid-agent` |
|
| macOS | `$HOME/Library/Application Support/MetaCrate/config.yml` | `$HOME/Library/Application Support/MetaCrate/grid-agent` |
|
||||||
| Windows | `%APPDATA%\MetaCrate\grid-agent.json` | `%LOCALAPPDATA%\MetaCrate\grid-agent` |
|
| Windows | `%APPDATA%\MetaCrate\config.yml` | `%LOCALAPPDATA%\MetaCrate/grid-agent` |
|
||||||
|
|
||||||
`METACRATE_AGENT_STORAGE_PATH` overrides the state directory. Relative paths in
|
Relative paths in a YAML document resolve relative to that document where specified; service
|
||||||
a JSON document resolve relative to that document where specified; service
|
|
||||||
deployments should use absolute paths.
|
deployments should use absolute paths.
|
||||||
|
|
||||||
## Endpoint, grid identity, and authority
|
## Endpoint, grid identity, and authority
|
||||||
@@ -59,11 +58,11 @@ deployments should use absolute paths.
|
|||||||
`llm.endpoint_url` is the exact OpenAI-compatible chat-completions URL. It is
|
`llm.endpoint_url` is the exact OpenAI-compatible chat-completions URL. It is
|
||||||
not a base URL: MetaCrate does not append a path, discover models, select a
|
not a base URL: MetaCrate does not append a path, discover models, select a
|
||||||
provider, or rewrite query parameters. `llm.api_key` is sent as the bearer key
|
provider, or rewrite query parameters. `llm.api_key` is sent as the bearer key
|
||||||
only to that exact origin; redirects are refused. Prefer
|
only to that exact origin; redirects are refused. Set `llm.model` when the
|
||||||
`METACRATE_AGENT_LLM_API_KEY_FILE` over inline JSON or direct environment text.
|
endpoint requires a model name. These values live in the private `config.yml`.
|
||||||
|
|
||||||
Live modes require `grid.login_url`, `grid.avatar_name`, and a password from
|
Live modes require `grid.login_url`, `grid.avatar_name`, and `grid.password`.
|
||||||
`METACRATE_AGENT_GRID_PASSWORD_FILE`. `authorized_avatar_uuids` contains exact
|
`authorized_avatar_uuids` contains exact
|
||||||
grid UUIDs, never display names. Text claiming an authorized identity grants no
|
grid UUIDs, never display names. Text claiming an authorized identity grants no
|
||||||
authority. Public chat can request bounded informational work and safe public
|
authority. Public chat can request bounded informational work and safe public
|
||||||
LSL delivery; movement, teleport, building, roaming changes, and administration
|
LSL delivery; movement, teleport, building, roaming changes, and administration
|
||||||
@@ -72,19 +71,22 @@ or a narrowly bound scheduler grant as documented in the policy matrix.
|
|||||||
|
|
||||||
## Configuration contract and migration
|
## Configuration contract and migration
|
||||||
|
|
||||||
The JSON root uses `"schema_version": 1`. Omitting it is accepted as legacy
|
The YAML root uses `schema_version: 1`. Omitting it is accepted as legacy
|
||||||
version 1. Any other value fails before startup with a migration message. There
|
version 1. Any other value fails before startup with a migration message. There
|
||||||
is no automatic in-place migration: copy the file, update the copy using the
|
is no automatic in-place migration: copy the file, update the copy using the
|
||||||
release notes/example, validate the copy, then atomically select it. Unknown
|
release notes/example, validate the copy, then atomically select it. Unknown
|
||||||
fields fail closed. Installers never overwrite or migrate operator files.
|
fields fail closed. Installers never overwrite or migrate operator files.
|
||||||
|
|
||||||
Precedence, lowest to highest, is:
|
Resolution order is:
|
||||||
|
|
||||||
1. bounded built-in defaults;
|
1. bounded built-in defaults;
|
||||||
2. JSON configuration;
|
2. the platform `config.yml` or explicit `--config` document;
|
||||||
3. secret files named by JSON;
|
3. legacy secret files referenced by that document.
|
||||||
4. environment-referenced secret files;
|
|
||||||
5. direct environment values.
|
Process environment variables do not override connection, mode, or authority.
|
||||||
|
Migrate an existing `.env` once with `metacrate-grid-agent --import-env
|
||||||
|
/path/to/.env`, inspect it using `--preferences`, then remove the old file under
|
||||||
|
the operator's retention policy. JSON remains readable only for migration.
|
||||||
|
|
||||||
Mode, endpoints, credentials, authorization UUIDs, TLS, storage, queue/resource
|
Mode, endpoints, credentials, authorization UUIDs, TLS, storage, queue/resource
|
||||||
limits, reconnect policy, behavior, and interaction settings are restart-only.
|
limits, reconnect policy, behavior, and interaction settings are restart-only.
|
||||||
@@ -120,9 +122,8 @@ Rust's portable metadata API cannot prove arbitrary Windows ACL semantics, and
|
|||||||
FAT/network filesystems may not enforce them. Treat an unverifiable filesystem
|
FAT/network filesystems may not enforce them. Treat an unverifiable filesystem
|
||||||
as unsuitable for unattended secrets.
|
as unsuitable for unattended secrets.
|
||||||
|
|
||||||
Rotate one credential at a time: write a new restricted file beside the old
|
Rotate one credential at a time in `--preferences` (or an ACL-restricted copy
|
||||||
one, atomically replace or repoint the `_FILE` setting, run `--check-config`,
|
of `config.yml`), save, run `--check-config`, then restart. Revoke the old credential only after readiness. Control observer
|
||||||
then restart. Revoke the old credential only after readiness. Control observer
|
|
||||||
and operator tokens must differ from each other and from grid/LLM credentials.
|
and operator tokens must differ from each other and from grid/LLM credentials.
|
||||||
|
|
||||||
## Linux systemd
|
## Linux systemd
|
||||||
@@ -130,16 +131,15 @@ and operator tokens must differ from each other and from grid/LLM credentials.
|
|||||||
The hardened example is
|
The hardened example is
|
||||||
[`../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service`](../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service).
|
[`../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service`](../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service).
|
||||||
Create the unprivileged `metacrate-agent` identity, copy (do not overwrite) an
|
Create the unprivileged `metacrate-agent` identity, copy (do not overwrite) an
|
||||||
operator configuration to `/etc/metacrate/grid-agent.json`, create
|
operator configuration to `/etc/metacrate/config.yml` and create
|
||||||
`/var/lib/metacrate/grid-agent`, and copy the environment-file example. The
|
`/var/lib/metacrate/grid-agent`. Keep the configuration owned by the service
|
||||||
environment file contains only secret *paths*. Put the actual secret files in
|
identity with mode `0600`; do not put credentials in the unit environment.
|
||||||
`/etc/metacrate/secrets`, owned by the service identity with mode `0600`.
|
|
||||||
|
|
||||||
Install the unit, inspect the sandbox, validate, then start:
|
Install the unit, inspect the sandbox, validate, then start:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo systemd-analyze security packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service
|
sudo systemd-analyze security packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service
|
||||||
sudo -u metacrate-agent /usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json --check-config
|
sudo -u metacrate-agent /usr/local/bin/metacrate-grid-agent --config /etc/metacrate/config.yml --check-config
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now metacrate-grid-agent.service
|
sudo systemctl enable --now metacrate-grid-agent.service
|
||||||
```
|
```
|
||||||
@@ -150,19 +150,18 @@ writes only under the state directory.
|
|||||||
|
|
||||||
## Windows service operation
|
## Windows service operation
|
||||||
|
|
||||||
Run the same `.exe`, JSON, control TCP protocol, and shutdown path. Portable
|
Run the same `.exe`, YAML, control TCP protocol, and shutdown path. Portable
|
||||||
foreground operation in PowerShell is the baseline:
|
foreground operation in PowerShell is the baseline:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\grid-agent.json' --check-config
|
& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\config.yml' --check-config
|
||||||
& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\grid-agent.json'
|
& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\config.yml'
|
||||||
```
|
```
|
||||||
|
|
||||||
For unattended use, configure a maintained Windows service wrapper (for
|
For unattended use, configure a maintained Windows service wrapper (for
|
||||||
example WinSW) to launch exactly that command as a dedicated low-privilege
|
example WinSW) to launch exactly that command as a dedicated low-privilege
|
||||||
account and to translate SCM Stop into Ctrl-C/console control before its timeout.
|
account and to translate SCM Stop into Ctrl-C/console control before its timeout.
|
||||||
Keep secrets in separate ACL-restricted files and expose only `_FILE` paths in
|
Keep `config.yml` ACL-restricted to the service identity. Configure restart-on-failure, not unconditional rapid
|
||||||
the wrapper environment. Configure restart-on-failure, not unconditional rapid
|
|
||||||
restart. Validate as the service identity before registration. The wrapper must
|
restart. Validate as the service identity before registration. The wrapper must
|
||||||
not capture environment values or command output into a world-readable log.
|
not capture environment values or command output into a world-readable log.
|
||||||
The supplied PowerShell installer replaces only the executable and never state.
|
The supplied PowerShell installer replaces only the executable and never state.
|
||||||
|
|||||||
@@ -8,21 +8,29 @@ Run an embedded UI with a live-grid integrated configuration:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run --locked -p metacrate-grid-agent --features live-grid -- \
|
cargo run --locked -p metacrate-grid-agent --features live-grid -- \
|
||||||
--config config/grid-agent.json --tui
|
--config config/grid-agent.integrated.example.yml --tui
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a separate UI against a loopback split service:
|
Run a separate UI against a loopback split service:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run --locked -p metacrate-grid-agent -- \
|
cargo run --locked -p metacrate-grid-agent -- \
|
||||||
--config config/grid-agent.json --tui-client
|
--config config/grid-agent.split.example.yml --tui-client
|
||||||
```
|
```
|
||||||
|
|
||||||
The split client reads its operator capability through the normal secret-file
|
The split client reads its operator capability from the ACL-restricted
|
||||||
or environment configuration. It never displays or copies that value. Remote
|
`config.yml`. It never displays or copies that value. Remote
|
||||||
TLS clients must embed `TuiTransport` with their explicitly configured trust
|
TLS clients must embed `TuiTransport` with their explicitly configured trust
|
||||||
roots; the command-line client intentionally accepts loopback TCP only.
|
roots; the command-line client intentionally accepts loopback TCP only.
|
||||||
|
|
||||||
|
Open preferences before a service is configured with
|
||||||
|
`metacrate-grid-agent --preferences`; the same panel is available from the live
|
||||||
|
TUI. It edits the endpoint, optional model, grid identity, hidden credentials,
|
||||||
|
mode, and privileged UUID list while preserving advanced YAML settings. Use
|
||||||
|
Up/Down to select, Enter to edit/commit, `w` to save, and Esc to cancel. Changes
|
||||||
|
to connection or authorization settings apply after restart. A one-time
|
||||||
|
`--import-env /path/to/.env` migrates legacy `GRID_*` and `OPENAPI_*` values.
|
||||||
|
|
||||||
Use Tab/arrow keys to change panels and scroll. `r` refreshes; `p`/`u` pause or
|
Use Tab/arrow keys to change panels and scroll. `r` refreshes; `p`/`u` pause or
|
||||||
resume; `f` reconnects the grid; `a`/`d` approve or deny the first pending
|
resume; `f` reconnects the grid; `a`/`d` approve or deny the first pending
|
||||||
approval; `x` cancels the newest visible action; `e` expires the first visible
|
approval; `x` cancels the newest visible action; `e` expires the first visible
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# Values here are paths and non-secret mode settings only. Never put secret
|
|
||||||
# values in this file. Create every referenced file as mode 0600, owned by the
|
|
||||||
# metacrate-agent service identity.
|
|
||||||
METACRATE_AGENT_LLM_API_KEY_FILE=/etc/metacrate/secrets/llm-api-key
|
|
||||||
METACRATE_AGENT_GRID_PASSWORD_FILE=/etc/metacrate/secrets/grid-password
|
|
||||||
METACRATE_AGENT_CONTROL_OPERATOR_TOKEN_FILE=/etc/metacrate/secrets/control-operator-token
|
|
||||||
METACRATE_AGENT_STORAGE_PATH=/var/lib/metacrate/grid-agent
|
|
||||||
@@ -8,9 +8,8 @@ After=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=metacrate-agent
|
User=metacrate-agent
|
||||||
Group=metacrate-agent
|
Group=metacrate-agent
|
||||||
EnvironmentFile=-/etc/metacrate/grid-agent.env
|
ExecStartPre=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/config.yml --check-config
|
||||||
ExecStartPre=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json --check-config
|
ExecStart=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/config.yml
|
||||||
ExecStart=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json
|
|
||||||
KillSignal=SIGINT
|
KillSignal=SIGINT
|
||||||
TimeoutStopSec=30s
|
TimeoutStopSec=30s
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|||||||
Reference in New Issue
Block a user