Skip to content

Commit 46f259f

Browse files
committed
feat(local): prove first local inference
Refresh the local discovery and first-inference proof on the merged Router base. Add ADR 0086 to make operator-owned runtime state and genuine loaded-model evidence explicit; retain bounded prompt-free receipts and fail closed when local delivery is not proven. [roadmap:WF-ROADMAP-0017]
1 parent af4f6e4 commit 46f259f

10 files changed

Lines changed: 866 additions & 11 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ details, release history over commit history.
88

99
### Added
1010

11+
- **Operator-owned local runtime proof** (WF-ADR-0086). Local discovery and
12+
first-inference checks distinguish configured from genuinely proven delivery;
13+
mocks validate only the contract and never stand in for loaded-model evidence.
14+
15+
- **Proven local first run** (WF-ADR-0087, issue #207). The Router can discover
16+
model IDs only from fixed literal-loopback runtime catalogs and run one fixed
17+
public inference probe. The versioned probe succeeds only when the matching
18+
bounded receipt proves a terminal on-device or local-network delivery; it
19+
emits no response text, credentials, repository paths, tool arguments, or
20+
private reasoning and never installs, pulls, selects, or activates a model
21+
silently.
22+
1123
- **Prompt-free outcomes and review-only local policy proposals** (WF-ADR-0085,
1224
issue #207). Authenticated local operators can attach an explicit success,
1325
correction, or failure to a retained terminal workspace receipt without

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ Install the flagship Omarchy experience:
1313
omarchy plugin add https://github.com/asdecided/omarchy-wayfinder.git
1414
cd ~/.config/omarchy/plugins/io.github.asdecided.wayfinder
1515
./install.sh
16-
wayfinder-router init
16+
wayfinder-router local discover --json
17+
wayfinder-router init --preset local --endpoint http://127.0.0.1:11434/v1 --model qwen2.5-coder:7b
1718
wayfinder-router doctor
19+
wayfinder-router serve
20+
wayfinder-router local probe --model local --json
1821
```
1922

23+
Choose the endpoint and model from the discovery output rather than copying the example blindly. Discovery touches only fixed loopback catalogs and never installs or pulls a model; setup is not complete until the fixed public inference probe returns a local execution receipt.
24+
2025
Point Codex, Claude Code, OpenCode, Pi, or Aider at the same loopback policy with
2126
`wayfinder-router connect <client>`. See the
2227
[verified coding-agent quick starts](docs/coding-agent-quickstarts.md), or run
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
schema_version: 1
3+
id: WF-ADR-0086
4+
type: decision
5+
status: accepted
6+
date: 2026-08-31
7+
tags: [omarchy, local, runtime, evidence, privacy]
8+
---
9+
10+
# Keep local runtime ownership with the operator and prove delivery
11+
12+
## Context
13+
14+
Wayfinder can generate a local configuration without proving that a model is
15+
actually loaded. A first-run surface must distinguish configured, discoverable,
16+
and proven states without installing software, downloading weights, scanning
17+
the network, or claiming that a hosted fallback was local.
18+
19+
## Decision
20+
21+
1. Runtime installation, model downloads, endpoint selection, and policy
22+
activation remain explicit operator actions. Wayfinder may inspect only the
23+
fixed literal-loopback catalogs and an endpoint the operator supplies.
24+
2. A local first-run proof consists of one bounded fixed public request through
25+
the running Router, a non-empty normalized response, and the matching
26+
terminal prompt-free receipt. The receipt must identify an on-device or
27+
local-network execution boundary.
28+
3. The proof is observational and single-shot. It never stores prompt or
29+
response content, credentials, repository paths, tool arguments, or private
30+
reasoning, and it never changes routing configuration.
31+
4. A missing runtime, unloaded model, unavailable receipt, hosted boundary,
32+
failed response, or ambiguous route is visibly `not-ready`; no health or
33+
discovery response may promote it to ready.
34+
5. The acceptance surface is repeatable on a real operator-owned runtime. A
35+
mock HTTP fixture may test parsing and bounds, but it cannot be presented as
36+
evidence that a model was loaded or that local inference succeeded.
37+
38+
## Consequences
39+
40+
Omarchy gets a truthful setup gate while the operator retains control of
41+
runtime and model state. CI can validate the bounded contract and a real local
42+
runtime can supply the final loaded-model evidence; environments without one
43+
must report that proof as unavailable rather than passing by simulation.
44+
45+
## Related
46+
47+
- WF-ADR-0001 (offline deterministic decision path)
48+
- WF-ADR-0068 (Omarchy Quattro plugin boundary)
49+
- WF-ADR-0070 (native activation surface)
50+
- WF-ADR-0085 (prompt-free outcomes and review-only policy proposals)
51+
- WF-ADR-0087 (first local inference evidence)
52+
- WF-ROADMAP-0017 (Omarchy-first delivery)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
schema_version: 1
3+
id: WF-ADR-0087
4+
type: decision
5+
status: accepted
6+
date: 2026-08-31
7+
tags: [omarchy, onboarding, local, privacy, evidence]
8+
---
9+
10+
# Prove a local inference before declaring first-run success
11+
12+
## Context
13+
14+
The native `local` starter preset names Ollama and `llama3.1`, but creating and parsing that file does not prove that either the runtime or model exists. An Omarchy surface can therefore report a configured service as ready while the first agent request will fail. Installing a runtime, pulling a model, scanning arbitrary ports, or choosing a discovered model without review would cross ownership, network, storage, and policy boundaries.
15+
16+
## Decision
17+
18+
1. `wayfinder-router local discover --json` queries only a fixed catalog of literal-loopback runtime endpoints for Ollama, LM Studio, llama.cpp, and vLLM. It emits `wf-local-discovery-v1` candidates containing only runtime name, loopback endpoint, fixed route ID, and public model ID. It does not install, pull, select, or write anything, and it does not scan the network.
19+
2. `wayfinder-router init --preset local --endpoint URL --model MODEL` accepts one explicitly selected discovery result. The endpoint must remain literal loopback, the model ID is bounded, the generated policy is parse-validated, and the existing no-clobber create contract remains authoritative.
20+
3. `wayfinder-router local probe --endpoint URL --model ROUTE_ID --json` sends one fixed, public, low-output request through an already running loopback Router. Success requires a non-empty normalized response plus the exact bounded receipt for that request with a terminal success and an `on-device` or `local-network` execution boundary.
21+
4. The `wf-local-probe-v1` report includes endpoint, route, served destination, execution boundary, timestamp, request count, and the fixed-prompt disclosure. It never includes response text, prompt text beyond the public disclosure label, provider payloads, credentials, repository paths, tool arguments, or private reasoning.
22+
5. Consumers may call setup complete only after the probe passes. An empty discovery, failed probe, missing receipt, hosted boundary, missing capability, or older Router remains visibly not ready.
23+
24+
## Consequences
25+
26+
- First run distinguishes “configuration exists” from “a local model actually answered.”
27+
- Operators keep control of runtime installation, model downloads, and policy selection.
28+
- Discovery intentionally misses runtimes on nonstandard ports until the operator supplies and reviews an explicit loopback endpoint.
29+
- Hosted and account-backed first runs remain separate provider contracts; this decision does not import credentials or change Automatic routing.
30+
31+
## Related
32+
33+
- WF-ADR-0001 (offline deterministic decision path)
34+
- WF-ADR-0068 (Omarchy plugin boundary)
35+
- WF-ADR-0070 (native activation surface)
36+
- WF-ADR-0073 (Omarchy-first portable core)
37+
- WF-ADR-0086 (local runtime ownership and proof)
38+
- WF-ROADMAP-0017 (Omarchy-first delivery)

docs/coding-agent-quickstarts.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,26 @@ the selected destination.
77

88
## Start Wayfinder
99

10-
From a directory that does not already contain `wayfinder-router.toml`:
10+
For an honest local-only first run, discover only the supported fixed-loopback runtime catalogs and review one candidate:
11+
12+
```sh
13+
wayfinder-router local discover --json
14+
wayfinder-router init --preset local \
15+
--endpoint http://127.0.0.1:11434/v1 \
16+
--model qwen2.5-coder:7b
17+
wayfinder-router doctor
18+
wayfinder-router serve
19+
```
20+
21+
In another terminal, prove one real delivery through the running Router:
22+
23+
```sh
24+
wayfinder-router local probe --model local --json
25+
```
26+
27+
Discovery never scans arbitrary ports, installs a runtime, pulls a model, selects a candidate, or writes configuration. `init` retains its no-clobber contract. The fixed public probe reports `passed` only when its matching bounded receipt proves a successful on-device or local-network execution; it does not emit the request or response text. An empty candidate list means that no supported fixed-loopback catalog answered, not that the machine has no local runtime.
28+
29+
For a two-arm local/hosted policy, start from a directory that does not already contain `wayfinder-router.toml`:
1130

1231
```sh
1332
wayfinder-router init --preset hybrid

llms.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ cd ~/.config/omarchy/plugins/io.github.asdecided.wayfinder
1414

1515
Then ask the user to open the Wayfinder bar item and choose **Set up Wayfinder**. The plugin creates only a missing policy, validates it, and installs the independent user service. Never replace an existing policy, provider credential, coding-agent configuration, or independently installed Router binary.
1616

17+
For the local-only first-success path, discover fixed loopback catalogs, explicitly choose a returned model, initialize a no-clobber policy, start the Router, and prove one real routed response:
18+
19+
```sh
20+
wayfinder-router local discover --json
21+
wayfinder-router init --preset local --endpoint http://127.0.0.1:11434 --model MODEL
22+
wayfinder-router serve
23+
wayfinder-router local probe --endpoint http://127.0.0.1:8787 --model local --json
24+
```
25+
26+
Discovery never scans the network, installs a runtime, pulls a model, or chooses on the user's behalf. The probe sends one fixed public prompt and reports only bounded delivery evidence; it never prints the model response. A policy is not created and readiness is not claimed unless every explicit step succeeds.
27+
1728
For a two-arm local/OpenAI policy outside the panel, run:
1829

1930
```sh

rust/crates/wayfinder-cli/src/activation_command.rs

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ use std::process::Command;
77
#[cfg(test)]
88
use std::fs;
99

10-
use crate::app_setup_command::{preset_config, write_new_config};
10+
use crate::app_setup_command::{local_preset_config, preset_config, write_new_config};
11+
use crate::local_command::{validate_identifier, validate_loopback_endpoint};
1112
use crate::{EXIT_OK, EXIT_USAGE, expand_tilde, write_error, write_output};
1213

1314
const DEFAULT_CONFIG: &str = "wayfinder-router.toml";
1415
const DEFAULT_ENDPOINT: &str = "http://127.0.0.1:8088";
15-
const INIT_HELP: &str =
16-
"usage: wayfinder-router init [--preset local|hybrid|openai|gemini|apple-local] [--path PATH]";
16+
const INIT_HELP: &str = concat!(
17+
"usage: wayfinder-router init [--preset local|hybrid|openai|gemini|apple-local] [--path PATH]\n",
18+
" wayfinder-router init --preset local --endpoint URL --model MODEL [--path PATH]"
19+
);
1720
const CONNECT_HELP: &str =
1821
"usage: wayfinder-router connect codex|claude-code|opencode|pi|aider [--endpoint URL]";
1922
const OPEN_HELP: &str = "usage: wayfinder-router open [--print]";
@@ -29,6 +32,8 @@ pub(crate) fn run_init(
2932
}
3033
let mut preset = "local".to_owned();
3134
let mut path = PathBuf::from(DEFAULT_CONFIG);
35+
let mut endpoint = None;
36+
let mut model = None;
3237
let mut index = 0;
3338
while let Some(argument) = arguments.get(index) {
3439
match argument.as_str() {
@@ -46,15 +51,55 @@ pub(crate) fn run_init(
4651
}
4752
None => return usage_error(stderr, "--path needs a value"),
4853
},
54+
"--endpoint" if endpoint.is_none() => match arguments.get(index + 1) {
55+
Some(value) => {
56+
endpoint = Some(value.clone());
57+
index += 1;
58+
}
59+
None => return usage_error(stderr, "--endpoint needs a value"),
60+
},
61+
"--model" if model.is_none() => match arguments.get(index + 1) {
62+
Some(value) => {
63+
model = Some(value.clone());
64+
index += 1;
65+
}
66+
None => return usage_error(stderr, "--model needs a value"),
67+
},
68+
"--endpoint" | "--model" => {
69+
return usage_error(stderr, &format!("{argument} may only be supplied once"));
70+
}
4971
value => return usage_error(stderr, &format!("unrecognized init argument: {value}")),
5072
}
5173
index += 1;
5274
}
53-
let contents = match preset_config(&preset) {
54-
Ok(Some(contents)) => contents,
55-
Ok(None) => return usage_error(stderr, &format!("unknown init preset: {preset}")),
56-
Err(message) => return usage_error(stderr, &message),
75+
let custom_local = match (endpoint, model) {
76+
(None, None) => None,
77+
(Some(endpoint), Some(model)) if preset == "local" => {
78+
if let Err(message) = validate_loopback_endpoint(&endpoint) {
79+
return usage_error(stderr, &message);
80+
}
81+
if let Err(message) = validate_identifier(&model, "model id") {
82+
return usage_error(stderr, &message);
83+
}
84+
match local_preset_config(&endpoint, &model) {
85+
Ok(contents) => Some(contents),
86+
Err(message) => return usage_error(stderr, &message),
87+
}
88+
}
89+
(Some(_), Some(_)) => {
90+
return usage_error(
91+
stderr,
92+
"--endpoint and --model are available only with --preset local",
93+
);
94+
}
95+
_ => return usage_error(stderr, "--endpoint and --model must be supplied together"),
5796
};
97+
let contents =
98+
match custom_local.map_or_else(|| preset_config(&preset), |value| Ok(Some(value))) {
99+
Ok(Some(contents)) => contents,
100+
Ok(None) => return usage_error(stderr, &format!("unknown init preset: {preset}")),
101+
Err(message) => return usage_error(stderr, &message),
102+
};
58103
match write_new_config(&path, &contents) {
59104
Ok(()) => {
60105
write_output(
@@ -221,6 +266,64 @@ mod tests {
221266
Ok(())
222267
}
223268

269+
#[test]
270+
fn init_accepts_an_explicit_discovered_local_model_without_clobbering()
271+
-> Result<(), Box<dyn std::error::Error>> {
272+
let root = std::env::temp_dir().join(format!("wayfinder-init-{}", uuid::Uuid::new_v4()));
273+
let path = root.join("wayfinder-router.toml");
274+
let arguments = vec![
275+
"--preset".to_owned(),
276+
"local".to_owned(),
277+
"--endpoint".to_owned(),
278+
"http://127.0.0.1:11434/v1".to_owned(),
279+
"--model".to_owned(),
280+
"qwen2.5-coder:7b".to_owned(),
281+
"--path".to_owned(),
282+
path.display().to_string(),
283+
];
284+
let mut stdout = Vec::new();
285+
let mut stderr = Vec::new();
286+
assert_eq!(run_init(&arguments, &mut stdout, &mut stderr), EXIT_OK);
287+
let generated = fs::read_to_string(&path)?;
288+
assert!(generated.contains("base_url = \"http://127.0.0.1:11434/v1\""));
289+
assert!(generated.contains("model = \"qwen2.5-coder:7b\""));
290+
assert_eq!(run_init(&arguments, &mut stdout, &mut stderr), EXIT_USAGE);
291+
fs::remove_dir_all(root)?;
292+
Ok(())
293+
}
294+
295+
#[test]
296+
fn explicit_local_init_rejects_remote_and_partial_selection() {
297+
for arguments in [
298+
vec![
299+
"--preset".to_owned(),
300+
"local".to_owned(),
301+
"--endpoint".to_owned(),
302+
"https://example.com/v1".to_owned(),
303+
"--model".to_owned(),
304+
"model".to_owned(),
305+
],
306+
vec![
307+
"--preset".to_owned(),
308+
"local".to_owned(),
309+
"--model".to_owned(),
310+
"model".to_owned(),
311+
],
312+
vec![
313+
"--preset".to_owned(),
314+
"hybrid".to_owned(),
315+
"--endpoint".to_owned(),
316+
"http://127.0.0.1:11434/v1".to_owned(),
317+
"--model".to_owned(),
318+
"model".to_owned(),
319+
],
320+
] {
321+
let mut stdout = Vec::new();
322+
let mut stderr = Vec::new();
323+
assert_eq!(run_init(&arguments, &mut stdout, &mut stderr), EXIT_USAGE);
324+
}
325+
}
326+
224327
#[test]
225328
fn connect_only_accepts_loopback_and_renders_each_client() {
226329
for (client, marker) in [

rust/crates/wayfinder-cli/src/app_setup_command.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,34 @@ pub(crate) fn preset_config(name: &str) -> Result<Option<String>, String> {
217217
}
218218
}
219219

220+
pub(crate) fn local_preset_config(endpoint: &str, model: &str) -> Result<String, String> {
221+
let endpoint = serde_json::to_string(endpoint)
222+
.map_err(|error| format!("cannot render local endpoint: {error}"))?;
223+
let model = serde_json::to_string(model)
224+
.map_err(|error| format!("cannot render local model: {error}"))?;
225+
let generated = format!(
226+
"# Generated by wayfinder-router init (preset: local; explicit model).\n\
227+
[routing]\n\
228+
threshold = 1.0\n\n\
229+
[gateway]\n\
230+
offline = true\n\n\
231+
[gateway.models.local]\n\
232+
base_url = {endpoint}\n\
233+
model = {model}\n\
234+
cost_per_1k = 0.0\n"
235+
);
236+
routing_config_from_toml(
237+
&generated,
238+
"generated explicit local preset",
239+
None,
240+
TierOrderPolicy::StrictInput,
241+
)
242+
.map_err(|error| format!("generated local routing is invalid: {error}"))?;
243+
gateway_config_from_toml(&generated, "generated explicit local preset")
244+
.map_err(|error| format!("generated local gateway is invalid: {error}"))?;
245+
Ok(generated)
246+
}
247+
220248
const STARTER_CORPUS: &str =
221249
include_str!("../../../../benchmarks/blind/openai-cross-provider.jsonl");
222250
const STARTER_SEMANTIC_WEIGHT: f64 = 0.05;

0 commit comments

Comments
 (0)