Skip to content

Commit be9cefd

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/cdi-in-cluster
2 parents c07c0f8 + ed74a19 commit be9cefd

40 files changed

Lines changed: 2435 additions & 163 deletions

.github/workflows/docs-preview-pr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ jobs:
4949
find _build -name .buildinfo -exec rm {} \;
5050
5151
- name: Deploy preview
52+
if: github.event.pull_request.head.repo.full_name == github.repository
5253
uses: rossjrw/pr-preview-action@v1
5354
with:
5455
source-dir: ./_build/docs/

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,14 @@ These are the primary `mise` tasks for day-to-day development:
186186
| `tasks/` | `mise` task definitions and build scripts |
187187
| `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests |
188188
| `architecture/` | Architecture docs and plans |
189+
| `rfc/` | Request for Comments proposals |
189190
| `docs/` | User-facing documentation (Sphinx/MyST) |
190191
| `.agents/` | Agent skills and persona definitions |
191192

193+
## RFCs
194+
195+
For cross-cutting architectural decisions, API contract changes, or process proposals that need broad consensus, use the RFC process. RFCs live in `rfc/` — copy the template, fill it in, and open a PR for discussion. See [rfc/README.md](rfc/README.md) for the full lifecycle and guidelines on when to write an RFC versus a spike issue or architecture doc.
196+
192197
## Documentation
193198

194199
If your change affects user-facing behavior (new flags, changed defaults, new features, bug fixes that contradict existing docs), update the relevant pages under `docs/` in the same PR.

architecture/inference-routing.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,10 @@ File: `proto/inference.proto`
9292

9393
Key messages:
9494

95-
- `SetClusterInferenceRequest` -- `provider_name` + `model_id` + optional `no_verify` override, with verification enabled by default
96-
- `SetClusterInferenceResponse` -- `provider_name` + `model_id` + `version`
95+
- `SetClusterInferenceRequest` -- `provider_name` + `model_id` + `timeout_secs` + optional `no_verify` override, with verification enabled by default
96+
- `SetClusterInferenceResponse` -- `provider_name` + `model_id` + `timeout_secs` + `version`
9797
- `GetInferenceBundleResponse` -- `repeated ResolvedRoute routes` + `revision` + `generated_at_ms`
98-
- `ResolvedRoute` -- `name`, `base_url`, `protocols`, `api_key`, `model_id`, `provider_type`
98+
- `ResolvedRoute` -- `name`, `base_url`, `protocols`, `api_key`, `model_id`, `provider_type`, `timeout_secs`
9999

100100
## Data Plane (Sandbox)
101101

@@ -106,7 +106,7 @@ Files:
106106
- `crates/openshell-sandbox/src/lib.rs` -- inference context initialization, route refresh
107107
- `crates/openshell-sandbox/src/grpc_client.rs` -- `fetch_inference_bundle()`
108108

109-
In cluster mode, the sandbox starts a background refresh loop as soon as the inference context is created. The loop polls the gateway every 5 seconds by default (`OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS` override) and uses the bundle revision hash to skip no-op cache writes.
109+
In cluster mode, the sandbox starts a background refresh loop as soon as the inference context is created. The loop polls the gateway every 5 seconds by default (`OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS` override) and uses the bundle revision hash to skip no-op cache writes. The revision hash covers all route fields including `timeout_secs`, so any configuration change (provider, model, or timeout) triggers a cache update on the next poll.
110110

111111
### Interception flow
112112

@@ -143,7 +143,7 @@ If no pattern matches, the proxy returns `403 Forbidden` with `{"error": "connec
143143
### Route cache
144144

145145
- `InferenceContext` holds a `Router`, the pattern list, and an `Arc<RwLock<Vec<ResolvedRoute>>>` route cache.
146-
- In cluster mode, `spawn_route_refresh()` polls `GetInferenceBundle` every 30 seconds (`ROUTE_REFRESH_INTERVAL_SECS`). On failure, stale routes are kept.
146+
- In cluster mode, `spawn_route_refresh()` polls `GetInferenceBundle` every 5 seconds (`OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS`). On failure, stale routes are kept.
147147
- In file mode (`--inference-routes`), routes load once at startup from YAML. No refresh task is spawned.
148148
- In cluster mode, an empty initial bundle still enables the inference context so the refresh task can pick up later configuration.
149149

@@ -209,9 +209,11 @@ File: `crates/openshell-router/src/mock.rs`
209209

210210
Routes with `mock://` scheme endpoints return canned responses without making HTTP requests. Mock responses are protocol-aware (OpenAI chat completion, OpenAI completion, Anthropic messages, or generic JSON). Mock routes include an `x-openshell-mock: true` response header.
211211

212-
### HTTP client
212+
### Per-request timeout
213213

214-
The router uses a `reqwest::Client` with a 60-second timeout. Timeouts and connection failures map to `RouterError::UpstreamUnavailable`.
214+
Each `ResolvedRoute` carries a `timeout` field (`Duration`). The `reqwest::Client` has no global timeout; instead, each outgoing request applies `.timeout(route.timeout)` on the request builder. When `timeout_secs` is `0` in the proto message, the default of 60 seconds is used (defined as `DEFAULT_ROUTE_TIMEOUT` in `config.rs`). Timeouts and connection failures map to `RouterError::UpstreamUnavailable`.
215+
216+
Timeout changes propagate dynamically to running sandboxes. The bundle revision hash includes `timeout_secs`, so when the timeout is updated via `openshell inference update --timeout`, the refresh loop detects the revision change and updates the route cache within one polling interval (5 seconds by default).
215217

216218
## Standalone Route File
217219

@@ -297,13 +299,16 @@ The system route is stored as a separate `InferenceRoute` record in the gateway
297299

298300
Cluster inference commands:
299301

300-
- `openshell inference set --provider <name> --model <id>` -- configures user-facing cluster inference
301-
- `openshell inference set --system --provider <name> --model <id>` -- configures system inference
302+
- `openshell inference set --provider <name> --model <id> [--timeout <secs>]` -- configures user-facing cluster inference
303+
- `openshell inference set --system --provider <name> --model <id> [--timeout <secs>]` -- configures system inference
304+
- `openshell inference update [--provider <name>] [--model <id>] [--timeout <secs>]` -- updates individual fields without resetting others
302305
- `openshell inference get` -- displays both user and system inference configuration
303306
- `openshell inference get --system` -- displays only the system inference configuration
304307

305308
The `--provider` flag references a provider record name (not a provider type). The provider must already exist in the cluster and have a supported inference type (`openai`, `anthropic`, or `nvidia`).
306309

310+
The `--timeout` flag sets the per-request timeout in seconds for upstream inference calls. When omitted or set to `0`, the default of 60 seconds applies. Timeout changes propagate to running sandboxes within the route refresh interval (5 seconds by default).
311+
307312
Inference writes verify by default. `--no-verify` is the explicit opt-out for endpoints that are not up yet.
308313

309314
## Provider Discovery

architecture/sandbox.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -431,15 +431,21 @@ Landlock restricts the child process's filesystem access to an explicit allowlis
431431
1. Build path lists from `filesystem.read_only` and `filesystem.read_write`
432432
2. If `include_workdir` is true, add the working directory to `read_write`
433433
3. If both lists are empty, skip Landlock entirely (no-op)
434-
4. Create a Landlock ruleset targeting ABI V1:
434+
4. Create a Landlock ruleset targeting ABI V2:
435435
- Read-only paths receive `AccessFs::from_read(abi)` rights
436436
- Read-write paths receive `AccessFs::from_all(abi)` rights
437-
5. Call `ruleset.restrict_self()` -- this applies to the calling process and all descendants
437+
5. For each path, attempt `PathFd::new()`. If it fails:
438+
- `BestEffort`: Log a warning with the error classification (not found, permission denied, symlink loop, etc.) and skip the path. Continue building the ruleset from remaining valid paths.
439+
- `HardRequirement`: Return a fatal error, aborting the sandbox.
440+
6. If all paths failed (zero rules applied), return an error rather than calling `restrict_self()` on an empty ruleset (which would block all filesystem access)
441+
7. Call `ruleset.restrict_self()` -- this applies to the calling process and all descendants
438442

439-
Error behavior depends on `LandlockCompatibility`:
443+
Kernel-level error behavior (e.g., Landlock ABI unavailable) depends on `LandlockCompatibility`:
440444
- `BestEffort`: Log a warning and continue without filesystem isolation
441445
- `HardRequirement`: Return a fatal error, aborting the sandbox
442446

447+
**Baseline path filtering**: System-injected baseline paths (e.g., `/app`) are pre-filtered by `enrich_proto_baseline_paths()` / `enrich_sandbox_baseline_paths()` using `Path::exists()` before they reach Landlock. User-specified paths are not pre-filtered -- they are evaluated at Landlock apply time so misconfigurations surface as warnings or errors.
448+
443449
### Seccomp syscall filtering
444450

445451
**File:** `crates/openshell-sandbox/src/sandbox/linux/seccomp.rs`
@@ -962,7 +968,7 @@ flowchart LR
962968
| `EnforcementMode` | `Audit`, `Enforce` | What to do on L7 deny (log-only vs block) |
963969
| `L7EndpointConfig` | `{ protocol, tls, enforcement }` | Per-endpoint L7 configuration |
964970
| `L7Decision` | `{ allowed, reason, matched_rule }` | Result of L7 evaluation |
965-
| `L7RequestInfo` | `{ action, target }` | HTTP method + path for policy evaluation |
971+
| `L7RequestInfo` | `{ action, target, query_params }` | HTTP method, path, and decoded query multimap for policy evaluation |
966972

967973
### Access presets
968974

@@ -1041,7 +1047,7 @@ This enables credential injection on all HTTPS endpoints automatically, without
10411047

10421048
Implements `L7Provider` for HTTP/1.1:
10431049

1044-
- **`parse_request()`**: Reads up to 16 KiB of headers, parses the request line (method, path), determines body framing from `Content-Length` or `Transfer-Encoding: chunked` headers. Returns `L7Request` with raw header bytes (may include overflow body bytes).
1050+
- **`parse_request()`**: Reads up to 16 KiB of headers, parses the request line (method, path), decodes query parameters into a multimap, determines body framing from `Content-Length` or `Transfer-Encoding: chunked` headers. Returns `L7Request` with raw header bytes (may include overflow body bytes).
10451051

10461052
- **`relay()`**: Forwards request headers and body to upstream (handling Content-Length, chunked, and no-body cases), then reads and relays the full response back to the client.
10471053

@@ -1054,7 +1060,7 @@ Implements `L7Provider` for HTTP/1.1:
10541060
`relay_with_inspection()` in `crates/openshell-sandbox/src/l7/relay.rs` is the main relay loop:
10551061

10561062
1. Parse one HTTP request from client via the provider
1057-
2. Build L7 input JSON with `request.method`, `request.path`, plus the CONNECT-level context (host, port, binary, ancestors, cmdline)
1063+
2. Build L7 input JSON with `request.method`, `request.path`, `request.query_params`, plus the CONNECT-level context (host, port, binary, ancestors, cmdline)
10581064
3. Evaluate `data.openshell.sandbox.allow_request` and `data.openshell.sandbox.request_deny_reason`
10591065
4. Log the L7 decision (tagged `L7_REQUEST`)
10601066
5. If allowed (or audit mode): relay request to upstream and response back to client, then loop

architecture/security-policy.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ Controls which filesystem paths the sandboxed process can access. Enforced via L
320320
| `read_only` | `string[]` | `[]` | Paths accessible in read-only mode |
321321
| `read_write` | `string[]` | `[]` | Paths accessible in read-write mode |
322322

323-
**Enforcement mapping**: Each path becomes a Landlock `PathBeneath` rule. Read-only paths receive `AccessFs::from_read(ABI::V1)` permissions. Read-write paths receive `AccessFs::from_all(ABI::V1)` permissions (read, write, execute, create, delete, rename). All other paths are denied by the Landlock ruleset.
323+
**Enforcement mapping**: Each path becomes a Landlock `PathBeneath` rule. Read-only paths receive `AccessFs::from_read(ABI::V2)` permissions. Read-write paths receive `AccessFs::from_all(ABI::V2)` permissions (read, write, execute, create, delete, rename). All other paths are denied by the Landlock ruleset.
324324

325325
**Filesystem preparation**: Before the child process spawns, the supervisor creates any `read_write` directories that do not exist and sets their ownership to `process.run_as_user`:`process.run_as_group` via `chown()`. See `crates/openshell-sandbox/src/lib.rs` -- `prepare_filesystem()`.
326326

@@ -358,10 +358,16 @@ Controls Landlock LSM compatibility behavior. **Static field** -- immutable afte
358358

359359
| Value | Behavior |
360360
| ------------------ | --------------------------------------------------------------------------------------------------------------------------- |
361-
| `best_effort` | If Landlock is unavailable (older kernel, unprivileged container), log a warning and continue without filesystem sandboxing |
362-
| `hard_requirement` | If Landlock is unavailable, abort sandbox startup with an error |
361+
| `best_effort` | If Landlock is unavailable (older kernel, unprivileged container), log a warning and continue without filesystem sandboxing. Individual inaccessible paths (missing, permission denied, symlink loops) are skipped with a warning while remaining rules are still applied. If all paths fail, the sandbox continues without Landlock rather than applying an empty ruleset that would block all access. |
362+
| `hard_requirement` | If Landlock is unavailable or any configured path cannot be opened, abort sandbox startup with an error. |
363363

364-
See `crates/openshell-sandbox/src/sandbox/linux/landlock.rs` -- `compat_level()`.
364+
**Per-path error handling**: `PathFd::new()` (which wraps `open(path, O_PATH | O_CLOEXEC)`) can fail for several reasons beyond path non-existence: `EACCES` (permission denied), `ELOOP` (symlink loop), `ENAMETOOLONG`, `ENOTDIR`. Each failure is classified with a human-readable reason in logs. In `best_effort` mode, the path is skipped and ruleset construction continues. In `hard_requirement` mode, the error is fatal.
365+
366+
**Baseline path filtering**: The enrichment functions (`enrich_proto_baseline_paths`, `enrich_sandbox_baseline_paths`) pre-filter system-injected baseline paths (e.g., `/app`) by checking `Path::exists()` before adding them to the policy. This prevents missing baseline paths from reaching Landlock at all. User-specified paths are not pre-filtered — they are evaluated at Landlock apply time so that misconfigurations surface as warnings (`best_effort`) or errors (`hard_requirement`).
367+
368+
**Zero-rule safety check**: If all paths in the ruleset fail to open, `apply()` returns an error rather than calling `restrict_self()` on an empty ruleset. An empty Landlock ruleset with `restrict_self()` would block all filesystem access — the inverse of the intended degradation behavior. This error is caught by the outer `BestEffort` handler, which logs a warning and continues without Landlock.
369+
370+
See `crates/openshell-sandbox/src/sandbox/linux/landlock.rs` -- `compat_level()`, `try_open_path()`, `classify_path_fd_error()`, `classify_io_error()`.
365371

366372
```yaml
367373
landlock:
@@ -461,9 +467,14 @@ rules:
461467
- allow:
462468
method: GET
463469
path: "/repos/**"
470+
query:
471+
per_page: "1*"
464472
- allow:
465473
method: POST
466474
path: "/repos/*/issues"
475+
query:
476+
labels:
477+
any: ["bug*", "p1*"]
467478
```
468479

469480
#### `L7Allow`
@@ -473,8 +484,9 @@ rules:
473484
| `method` | `string` | HTTP method: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`, or `*` (any). Case-insensitive matching. |
474485
| `path` | `string` | URL path glob pattern: `**` matches everything, otherwise `glob.match` with `/` delimiter. |
475486
| `command` | `string` | SQL command: `SELECT`, `INSERT`, `UPDATE`, `DELETE`, or `*` (any). Case-insensitive matching. For `protocol: sql` endpoints. |
487+
| `query` | `map` | Optional REST query rules keyed by decoded query param name. Value is either a glob string (for example, `tag: "foo-*"`) or `{ any: ["foo-*", "bar-*"] }`. |
476488

477-
Method and command fields use `*` as wildcard for "any". Path patterns use `**` for "match everything" and standard glob patterns with `/` as a delimiter otherwise. See `sandbox-policy.rego` -- `method_matches()`, `path_matches()`, `command_matches()`.
489+
Method and command fields use `*` as wildcard for "any". Path patterns use `**` for "match everything" and standard glob patterns with `/` as a delimiter otherwise. Query matching is case-sensitive and evaluates decoded values; when duplicate keys are present in the request, every value for that key must match the configured matcher. See `sandbox-policy.rego` -- `method_matches()`, `path_matches()`, `command_matches()`, `query_params_match()`.
478490

479491
#### Access Presets
480492

@@ -716,7 +728,7 @@ If any condition fails, the proxy returns `403 Forbidden`.
716728
7. Rewrites the request: absolute-form → origin-form (`GET /path HTTP/1.1`), strips hop-by-hop headers, adds `Via: 1.1 openshell-sandbox` and `Connection: close`
717729
8. Forwards the rewritten request, then relays bidirectionally using `tokio::io::copy_bidirectional` (supports chunked transfer, SSE streams, and other long-lived responses with no idle timeout)
718730

719-
**V1 simplifications**: Forward proxy v1 injects `Connection: close` (no keep-alive) and does not perform L7 inspection on the forwarded traffic. Every forward proxy connection handles exactly one request-response exchange.
731+
**V1 simplifications**: Forward proxy v1 injects `Connection: close` (no keep-alive). Every forward proxy connection handles exactly one request-response exchange. When an endpoint has L7 rules configured, the forward proxy evaluates the single request's method and path against L7 policy before forwarding.
720732

721733
**Implementation**: See `crates/openshell-sandbox/src/proxy.rs` -- `handle_forward_proxy()`, `parse_proxy_uri()`, `rewrite_forward_request()`.
722734

crates/openshell-cli/src/main.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,10 @@ enum InferenceCommands {
942942
/// Skip endpoint verification before saving the route.
943943
#[arg(long)]
944944
no_verify: bool,
945+
946+
/// Request timeout in seconds for inference calls (0 = default 60s).
947+
#[arg(long, default_value_t = 0)]
948+
timeout: u64,
945949
},
946950

947951
/// Update gateway-level inference configuration (partial update).
@@ -962,6 +966,10 @@ enum InferenceCommands {
962966
/// Skip endpoint verification before saving the route.
963967
#[arg(long)]
964968
no_verify: bool,
969+
970+
/// Request timeout in seconds for inference calls (0 = default 60s, unchanged if omitted).
971+
#[arg(long)]
972+
timeout: Option<u64>,
965973
},
966974

967975
/// Get gateway-level inference provider and model.
@@ -2041,10 +2049,11 @@ async fn main() -> Result<()> {
20412049
model,
20422050
system,
20432051
no_verify,
2052+
timeout,
20442053
} => {
20452054
let route_name = if system { "sandbox-system" } else { "" };
20462055
run::gateway_inference_set(
2047-
endpoint, &provider, &model, route_name, no_verify, &tls,
2056+
endpoint, &provider, &model, route_name, no_verify, timeout, &tls,
20482057
)
20492058
.await?;
20502059
}
@@ -2053,6 +2062,7 @@ async fn main() -> Result<()> {
20532062
model,
20542063
system,
20552064
no_verify,
2065+
timeout,
20562066
} => {
20572067
let route_name = if system { "sandbox-system" } else { "" };
20582068
run::gateway_inference_update(
@@ -2061,6 +2071,7 @@ async fn main() -> Result<()> {
20612071
model.as_deref(),
20622072
route_name,
20632073
no_verify,
2074+
timeout,
20642075
&tls,
20652076
)
20662077
.await?;

0 commit comments

Comments
 (0)