Render an Apple configuration profile from the server's PACC document
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m9s
Publish release / publish (push) Successful in 1m52s

iOS is the one client none of the mail autoconfiguration reaches: Apple
Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so
adding an account means the device probing for ports - and a probe that
meets a filtered port waits out a TCP timeout and reports the account as
unverifiable, which the person reads as a wrong password.

A profile states the hosts and the ports instead, and carries mail,
calendars and contacts in one install. It invents none of them: site.yaml
names a PACC document - the standard defined to be fetched from the mail
domain itself - and every host, port and collection path comes from
there, so the server is described in one place and the profile follows it.

The document holds no address, username or password. iOS asks for those
at install, which means one profile serves everyone with an account and
it can be fetched by anyone: it says no more than PACC already says in
public. Identifiers are derived from the site and the payload rather than
drawn at random, because iOS replaces a profile whose ids it knows and
stacks a duplicate when they differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
Bendik Lynghaug
2026-10-07 11:18:46 +02:00
co-authored by Claude Opus 5
parent 43a2f3a069
commit 44c7ee6870
46 changed files with 6937 additions and 1519 deletions
+183 -37
View File
@@ -127,7 +127,12 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
continue;
}
let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()];
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
let done: Vec<&str> = need["done_when"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
for (run, route) in all_routes(bucket, &done).into_iter().enumerate() {
let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()];
states.extend(route.iter().map(|m| m.to.clone()));
@@ -137,7 +142,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
run,
target: states.last().cloned().unwrap_or_default(),
record: BTreeMap::from([("note".to_string(), json!("dry run"))]),
pressed: route.iter().map(|m| format!("{} -> {}", m.from, m.label)).collect(),
pressed: route
.iter()
.map(|m| format!("{} -> {}", m.from, m.label))
.collect(),
moved_by: route.iter().map(|m| m.by.clone()).collect(),
touches: route.len(),
ask_backs: Vec::new(),
@@ -148,9 +156,16 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
}
}
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
std::fs::write(
out.join("score.json"),
serde_json::to_string_pretty(&scores)?,
)?;
let relays: usize = scores.values().map(|s| s.cases).sum();
println!("dry run: {relays} route(s) across {} need(s) written to {}", scores.len(), out.join("cases.jsonl").display());
println!(
"dry run: {relays} route(s) across {} need(s) written to {}",
scores.len(),
out.join("cases.jsonl").display()
);
return Ok(());
}
@@ -180,7 +195,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
}
}
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
std::fs::write(
out.join("score.json"),
serde_json::to_string_pretty(&scores)?,
)?;
print_scores(&scores);
Ok(())
}
@@ -257,7 +275,8 @@ fn play(
);
let reply = ask(visitor_cmd, &prompt, &seed(i * 10 + 1))?;
if reply["abandon"] == true {
case.outcome = Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string());
case.outcome =
Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string());
return Ok(case);
}
if let Some(values) = reply["values"].as_object() {
@@ -277,9 +296,18 @@ fn play(
// collected, could this role really do what the button claims?
let bucket_name = need["lands_in"].as_str().unwrap_or_default();
let bucket = &model["buckets"][bucket_name];
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
let done: Vec<&str> = need["done_when"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
let initial = bucket["initial"].as_str().unwrap_or("open").to_string();
let target = done.get(run % done.len().max(1)).copied().unwrap_or_default().to_string();
let target = done
.get(run % done.len().max(1))
.copied()
.unwrap_or_default()
.to_string();
case.target = target.clone();
case.states.push(initial.clone());
let Some(route) = route_to(bucket, &initial, &target) else {
@@ -304,7 +332,11 @@ fn play(
serde_json::to_string_pretty(&case.record)?,
);
let reply = ask(handler_cmd, &prompt, &seed(100 + step))?;
let question = reply["need_to_know"].as_str().unwrap_or_default().trim().to_string();
let question = reply["need_to_know"]
.as_str()
.unwrap_or_default()
.trim()
.to_string();
if reply["can_act"] == false && !question.is_empty() {
let answer = ask(
visitor_cmd,
@@ -314,7 +346,10 @@ fn play(
&seed(200 + step),
)?;
let n = case.ask_backs.len() + 1;
case.record.insert(format!("follow_up_{n}"), json!({ "asked": question, "answered": answer["answer"] }));
case.record.insert(
format!("follow_up_{n}"),
json!({ "asked": question, "answered": answer["answer"] }),
);
case.ask_backs.push(AskBack {
state: from.clone(),
button: label.clone(),
@@ -343,21 +378,47 @@ pub(crate) struct Move {
/// desk, and the submitter's own link. Roles overlap on one aggregate,
/// so a route is a relay, not one desk's work.
pub(crate) fn all_moves(bucket: &Value) -> Vec<Move> {
let legal = |from: &str, to: &str| bucket["transitions"][from].as_array().is_some_and(|a| a.iter().any(|x| x == to));
let legal = |from: &str, to: &str| {
bucket["transitions"][from]
.as_array()
.is_some_and(|a| a.iter().any(|x| x == to))
};
let mut moves: Vec<Move> = Vec::new();
for desk in bucket["desks"].as_array().into_iter().flatten() {
for m in desk["moves"].as_array().into_iter().flatten() {
let (Some(from), Some(label), Some(to)) = (m["from"].as_str(), m["label"].as_str(), m["to"].as_str()) else { continue };
let (Some(from), Some(label), Some(to)) =
(m["from"].as_str(), m["label"].as_str(), m["to"].as_str())
else {
continue;
};
if legal(from, to) && !moves.iter().any(|x| x.from == from && x.to == to) {
moves.push(Move { from: from.into(), label: label.into(), to: to.into(), by: desk["group"].as_str().unwrap_or("ungated").into() });
moves.push(Move {
from: from.into(),
label: label.into(),
to: to.into(),
by: desk["group"].as_str().unwrap_or("ungated").into(),
});
}
}
}
let states: Vec<String> = bucket["transitions"].as_object().map(|t| t.keys().cloned().collect()).unwrap_or_default();
for to in bucket["self_moves"].as_array().into_iter().flatten().filter_map(Value::as_str) {
let states: Vec<String> = bucket["transitions"]
.as_object()
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();
for to in bucket["self_moves"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
for from in &states {
if legal(from, to) && !moves.iter().any(|x| &x.from == from && x.to == to) {
moves.push(Move { from: from.clone(), label: "their own link".into(), to: to.into(), by: SUBMITTER.into() });
moves.push(Move {
from: from.clone(),
label: "their own link".into(),
to: to.into(),
by: SUBMITTER.into(),
});
}
}
}
@@ -388,7 +449,13 @@ fn route_to(bucket: &Value, from: &str, to: &str) -> Option<Vec<Move>> {
/// dry run walks, so that each button of each role is pressed at least
/// once against the real engine.
pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
fn walk(state: &str, done: &[&str], moves: &[Move], path: &mut Vec<Move>, out: &mut Vec<Vec<Move>>) {
fn walk(
state: &str,
done: &[&str],
moves: &[Move],
path: &mut Vec<Move>,
out: &mut Vec<Vec<Move>>,
) {
if done.contains(&state) {
out.push(path.clone());
return;
@@ -403,7 +470,13 @@ pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
}
}
let mut out = Vec::new();
walk(bucket["initial"].as_str().unwrap_or("open"), done, &all_moves(bucket), &mut Vec::new(), &mut out);
walk(
bucket["initial"].as_str().unwrap_or("open"),
done,
&all_moves(bucket),
&mut Vec::new(),
&mut out,
);
out
}
@@ -424,7 +497,11 @@ pub(crate) fn resolve_choice(names: &[&str], raw: &str) -> String {
/// be (`FORMAT=json`, honoured by engines/ollama.sh) and the first
/// object in the reply is taken either way.
pub(crate) fn ask(cmd: &str, prompt: &str, seed: &str) -> anyhow::Result<Value> {
let reply = llm_with(cmd, prompt, &[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")])?;
let reply = llm_with(
cmd,
prompt,
&[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")],
)?;
Ok(first_object(&reply).unwrap_or(Value::Null))
}
@@ -462,7 +539,11 @@ fn tally(score: &mut NeedScore, case: &Case) {
score.buttons.entry(button.clone()).or_default().0 += 1;
}
for a in &case.ask_backs {
score.buttons.entry(format!("{} -> {}", a.state, a.button)).or_default().1 += 1;
score
.buttons
.entry(format!("{} -> {}", a.state, a.button))
.or_default()
.1 += 1;
}
match &case.outcome {
Outcome::Finished(_) => score.finished += 1,
@@ -476,7 +557,9 @@ fn tally(score: &mut NeedScore, case: &Case) {
}
Outcome::Abandoned(why) => {
score.abandoned += 1;
score.abandon_reasons.push(format!("{}: {why}", case.persona));
score
.abandon_reasons
.push(format!("{}: {why}", case.persona));
}
}
}
@@ -501,7 +584,15 @@ fn per_case(n: usize, s: &NeedScore) -> String {
fn print_scores(scores: &BTreeMap<String, NeedScore>) {
println!(
"\n{:<28} {:>5} {:>9} {:>9} {:>10} {:>9} {:>8} {:>10} {:>13}",
"need", "cases", "finished", "misroute", "abandoned", "no route", "touches", "ask-backs", "unanswerable"
"need",
"cases",
"finished",
"misroute",
"abandoned",
"no route",
"touches",
"ask-backs",
"unanswerable"
);
for (need, s) in scores {
println!(
@@ -529,7 +620,11 @@ fn print_scores(scores: &BTreeMap<String, NeedScore>) {
" {need} [{} / {}]{} {}",
a.state,
a.button,
if a.answerable { "" } else { " (visitor did not know)" },
if a.answerable {
""
} else {
" (visitor did not know)"
},
a.question
);
}
@@ -550,10 +645,16 @@ fn print_scores(scores: &BTreeMap<String, NeedScore>) {
pub fn compare(argv: &[String]) -> anyhow::Result<()> {
let (a, b) = match (argv.get(1), argv.get(2)) {
(Some(a), Some(b)) => (a, b),
_ => return Err(anyhow!("usage: portal-trainer compare <a/score.json> <b/score.json>")),
_ => {
return Err(anyhow!(
"usage: portal-trainer compare <a/score.json> <b/score.json>"
))
}
};
let load = |p: &String| -> anyhow::Result<BTreeMap<String, NeedScore>> {
Ok(serde_json::from_str(&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?)?)
Ok(serde_json::from_str(
&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?,
)?)
};
let (a, b) = (load(a)?, load(b)?);
println!(
@@ -565,11 +666,27 @@ pub fn compare(argv: &[String]) -> anyhow::Result<()> {
println!(
"{:<28} {:>14} {:>14} {:>14} {:>16} {:>16}",
need,
format!("{} -> {}", pct(sa.misrouted, sa.cases), pct(sb.misrouted, sb.cases)),
format!("{} -> {}", pct(sa.abandoned, sa.cases), pct(sb.abandoned, sb.cases)),
format!(
"{} -> {}",
pct(sa.misrouted, sa.cases),
pct(sb.misrouted, sb.cases)
),
format!(
"{} -> {}",
pct(sa.abandoned, sa.cases),
pct(sb.abandoned, sb.cases)
),
format!("{} -> {}", pct(sa.stuck, sa.cases), pct(sb.stuck, sb.cases)),
format!("{} -> {}", per_case(sa.touches, sa), per_case(sb.touches, sb)),
format!("{} -> {}", per_case(sa.ask_backs, sa), per_case(sb.ask_backs, sb)),
format!(
"{} -> {}",
per_case(sa.touches, sa),
per_case(sb.touches, sb)
),
format!(
"{} -> {}",
per_case(sa.ask_backs, sa),
per_case(sb.ask_backs, sb)
),
);
}
Ok(())
@@ -589,8 +706,15 @@ mod tests {
#[test]
fn a_choice_is_matched_by_its_heading_however_the_model_dresses_it() {
let names = ["Ask about working", "Ask about working with us", "Get a price"];
assert_eq!(resolve_choice(&names, "Ask about working with us: For carpenters who..."), "Ask about working with us");
let names = [
"Ask about working",
"Ask about working with us",
"Get a price",
];
assert_eq!(
resolve_choice(&names, "Ask about working with us: For carpenters who..."),
"Ask about working with us"
);
assert_eq!(resolve_choice(&names, " get a PRICE "), "Get a price");
assert_eq!(resolve_choice(&names, "Something else"), "Something else");
}
@@ -620,7 +744,10 @@ mod tests {
// Losing a quoted job is the other group's button, so the way
// from open to that ending is a relay: two roles, one case.
let route = route_to(&bucket(), "surveyed", "lost").unwrap();
let relay: Vec<(&str, &str)> = route.iter().map(|m| (m.label.as_str(), m.by.as_str())).collect();
let relay: Vec<(&str, &str)> = route
.iter()
.map(|m| (m.label.as_str(), m.by.as_str()))
.collect();
assert_eq!(relay, [("Quote sent", "pm"), ("Theirs", "other")]);
assert_eq!(route_to(&bucket(), "open", "open").unwrap().len(), 0);
assert!(route_to(&bucket(), "won", "open").is_none());
@@ -630,7 +757,10 @@ mod tests {
fn the_submitters_own_link_is_a_move_like_any_other() {
let mut b = bucket();
b["self_moves"] = json!(["lost"]);
let theirs: Vec<Move> = all_moves(&b).into_iter().filter(|m| m.by == SUBMITTER).collect();
let theirs: Vec<Move> = all_moves(&b)
.into_iter()
.filter(|m| m.by == SUBMITTER)
.collect();
// Only where the machine allows it and no desk already holds it:
// open->lost is the project manager's and quoted->lost the other
// group's, which leaves surveyed->lost to the link.
@@ -641,7 +771,10 @@ mod tests {
#[test]
fn a_dry_run_walks_every_route_to_every_ending() {
let routes = all_routes(&bucket(), &["won", "lost"]);
let ends: Vec<String> = routes.iter().map(|r| format!("{}:{}", r.len(), r.last().unwrap().to)).collect();
let ends: Vec<String> = routes
.iter()
.map(|r| format!("{}:{}", r.len(), r.last().unwrap().to))
.collect();
// open->lost; open->surveyed->quoted->won; and the relay
// open->surveyed->quoted->lost, closed by the other group.
assert_eq!(ends.len(), 3, "{ends:?}");
@@ -664,15 +797,28 @@ mod tests {
moved_by: vec!["pm".into(), "pm".into()],
touches: 3,
ask_backs: vec![
AskBack { state: "open".into(), button: "Looked".into(), question: "Which floor?".into(), answerable: true },
AskBack { state: "surveyed".into(), button: "Quote sent".into(), question: "Load-bearing?".into(), answerable: false },
AskBack {
state: "open".into(),
button: "Looked".into(),
question: "Which floor?".into(),
answerable: true,
},
AskBack {
state: "surveyed".into(),
button: "Quote sent".into(),
question: "Load-bearing?".into(),
answerable: false,
},
],
outcome,
};
tally(&mut score, &base(Outcome::Finished("won".into())));
tally(&mut score, &base(Outcome::Stuck("no route".into())));
tally(&mut score, &base(Outcome::Misrouted("Wrong door".into())));
assert_eq!((score.cases, score.finished, score.stuck, score.misrouted), (3, 1, 1, 1));
assert_eq!(
(score.cases, score.finished, score.stuck, score.misrouted),
(3, 1, 1, 1)
);
assert_eq!((score.ask_backs, score.unanswerable), (6, 3));
}
}