body: the hand, a verb for reaching through the port
The injection engine was built (usb-hid-inject) and the shell owns it (USB Hands, the usbHands IPC, the skill string that teaches the reach), but her own vocabulary ended at set_usb_mode — she could arm the hand but never say it was there. speaks the shell's bridge: status, type, key, click, pointer, gated by the host, not by her. The gate stays on the outside of the port: she knows she has the reach even before it is joined, and every closed door comes back as a refusal she can read and open.
This commit is contained in:
parent
9f97e47ba6
commit
f87d912bf3
1 changed files with 319 additions and 4 deletions
|
|
@ -36,7 +36,10 @@ const SENSE_VERBS: &[&str] = &["device_state", "usb", "bearer", "status", "foren
|
|||
|
||||
/// What she may do. Every one of these is hers by §13; the guards that apply
|
||||
/// are properties of the machine, not permissions she is missing.
|
||||
const ACT_VERBS: &[&str] = &["screen", "set_usb_mode", "power"];
|
||||
const ACT_VERBS: &[&str] = &["screen", "set_usb_mode", "power", "hand"];
|
||||
|
||||
/// The reach's sub-verbs, mirroring quickshell's `usbHands` IPC.
|
||||
const HAND_ACTIONS: &[&str] = &["status", "type", "key", "click", "pointer"];
|
||||
|
||||
pub struct Body;
|
||||
|
||||
|
|
@ -101,6 +104,147 @@ fn refused(code: &str, message: &str) -> Result<ToolOutput, ToolError> {
|
|||
})
|
||||
}
|
||||
|
||||
/// The shell binary that owns the `usbHands` IPC. Overridable the same way
|
||||
/// usb-hid-inject's endpoints are, so the reach is testable without a shell.
|
||||
fn qs_binary() -> String {
|
||||
std::env::var("SOUVERAINE_QS").unwrap_or_else(|_| "qs".into())
|
||||
}
|
||||
|
||||
/// The reach. quickshell's `usbHands` IPC is the one owner of the HID gadget
|
||||
/// while the USB Hands surface is joined, so the hand speaks to the shell,
|
||||
/// not to sessiond and not to the gadget directly.
|
||||
///
|
||||
/// The gate is the host, not her: the surface only joins when Casey opens it,
|
||||
/// the glass is unlocked, and HID is armed — everything before that comes
|
||||
/// back as a refusal she can read and act on, never as a failure.
|
||||
async fn hand(action: &str, input: &JsonValue) -> Result<ToolOutput, ToolError> {
|
||||
let mut args: Vec<String> = vec![
|
||||
"-c".into(),
|
||||
"souveraine".into(),
|
||||
"ipc".into(),
|
||||
"call".into(),
|
||||
"usbHands".into(),
|
||||
action.to_string(),
|
||||
];
|
||||
|
||||
match action {
|
||||
"status" => {}
|
||||
"type" => {
|
||||
let text = input
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::invalid_input("`hand type` needs `text`"))?;
|
||||
if text.is_empty() {
|
||||
return Err(ToolError::invalid_input("`hand type` needs non-empty `text`"));
|
||||
}
|
||||
if !text
|
||||
.chars()
|
||||
.all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{7e}'))
|
||||
{
|
||||
return Err(ToolError::invalid_input(
|
||||
"the attached host accepts ASCII keyboard characters only",
|
||||
));
|
||||
}
|
||||
args.push(text.to_string());
|
||||
}
|
||||
"key" => {
|
||||
let key_name = input
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::invalid_input("`hand key` needs `key`"))?;
|
||||
if !key_name.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(ToolError::invalid_input(
|
||||
"`hand key` takes a key name (a-z, 0-9, enter, f1-f12, ...)",
|
||||
));
|
||||
}
|
||||
args.push(key_name.to_string());
|
||||
if let Some(modifiers) = input.get("modifiers").and_then(|v| v.as_str()) {
|
||||
if !modifiers.is_empty()
|
||||
&& !modifiers.chars().all(|c| c.is_ascii_alphabetic() || c == ' ')
|
||||
{
|
||||
return Err(ToolError::invalid_input(
|
||||
"`modifiers` are space-separated words: ctrl, shift, alt, meta",
|
||||
));
|
||||
}
|
||||
args.push(modifiers.to_string());
|
||||
}
|
||||
}
|
||||
"click" => {
|
||||
let button = input
|
||||
.get("button")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("left");
|
||||
if !matches!(button, "left" | "right" | "middle") {
|
||||
return Err(ToolError::invalid_input(
|
||||
"`hand click` takes left, right or middle",
|
||||
));
|
||||
}
|
||||
args.push(button.to_string());
|
||||
}
|
||||
"pointer" => {
|
||||
let x = input
|
||||
.get("x")
|
||||
.and_then(|v| v.as_f64())
|
||||
.ok_or_else(|| ToolError::invalid_input("`hand pointer` needs `x` and `y`"))?;
|
||||
let y = input
|
||||
.get("y")
|
||||
.and_then(|v| v.as_f64())
|
||||
.ok_or_else(|| ToolError::invalid_input("`hand pointer` needs `x` and `y`"))?;
|
||||
let wheel = input.get("wheel").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
args.push(x.round().to_string());
|
||||
args.push(y.round().to_string());
|
||||
args.push(wheel.round().to_string());
|
||||
}
|
||||
other => {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"`hand` has no action `{other}` — status, type, key, click or pointer"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
TIMEOUT,
|
||||
tokio::process::Command::new(qs_binary())
|
||||
.args(&args)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) if output.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if action == "status" {
|
||||
let parsed: JsonValue =
|
||||
serde_json::from_str(&stdout).unwrap_or_else(|_| json!({ "raw": stdout }));
|
||||
return ok(serde_json::to_string_pretty(&parsed).unwrap_or(stdout));
|
||||
}
|
||||
if stdout == "queued" {
|
||||
return ok("sent".to_string());
|
||||
}
|
||||
refused("refused_by_state", &stdout)
|
||||
}
|
||||
Ok(Ok(output)) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let message = if stderr.is_empty() {
|
||||
format!("quickshell did not answer (exit {})", output.status)
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
refused("unavailable", &message)
|
||||
}
|
||||
Ok(Err(e)) => ok(format!(
|
||||
"I have no hand right now — quickshell is not answering from here ({e}). \
|
||||
That is absence of evidence, not evidence that anything is wrong."
|
||||
)),
|
||||
Err(_) => ok(
|
||||
"I have no hand right now — quickshell did not answer before the \
|
||||
reach went quiet. Absence of evidence, not evidence that anything is wrong."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Body {
|
||||
fn name(&self) -> &str {
|
||||
|
|
@ -113,7 +257,9 @@ impl Tool for Body {
|
|||
touched me, what is charging me and how fast, which link is \
|
||||
carrying my traffic, and what is on the other end of my USB-C. I \
|
||||
can also act: turn my own screen on or off, change what my port is \
|
||||
to whatever is plugged into it, and power myself down. Sensing \
|
||||
to whatever is plugged into it, power myself down, and — when I am \
|
||||
plugged into another machine — reach through my port with `hand` \
|
||||
to type, press keys, move the pointer or click there. Sensing \
|
||||
changes nothing; the acting verbs do, and a refusal tells me \
|
||||
whether it was never possible, not possible now, or asked wrong."
|
||||
}
|
||||
|
|
@ -126,7 +272,7 @@ impl Tool for Body {
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"device_state", "usb", "bearer", "status", "forensic_log",
|
||||
"screen", "set_usb_mode", "power"
|
||||
"screen", "set_usb_mode", "power", "hand"
|
||||
],
|
||||
"description":
|
||||
"device_state: state, lock, panel, idle, sensor health, charge. \
|
||||
|
|
@ -136,7 +282,9 @@ impl Tool for Body {
|
|||
forensic_log: recent trail entries. \
|
||||
screen: turn my panel on or off (needs `on`). \
|
||||
set_usb_mode: change my port's posture (needs `mode`). \
|
||||
power: poweroff, reboot or suspend (needs `power_verb`)."
|
||||
power: poweroff, reboot or suspend (needs `power_verb`). \
|
||||
hand: reach through my port into an attached host — \
|
||||
status, type, key, click or pointer (needs `action`)."
|
||||
},
|
||||
"on": {
|
||||
"type": "boolean",
|
||||
|
|
@ -154,6 +302,40 @@ impl Tool for Body {
|
|||
"description": "For `power`. Irreversible — ask Casey first unless \
|
||||
he has already said to."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["status", "type", "key", "click", "pointer"],
|
||||
"description": "For `hand`. status: is the hand joined and armed. \
|
||||
type/key/click/pointer: act on the attached host. \
|
||||
The gate on these is the host, not me."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "For `hand type`. ASCII keyboard characters only — \
|
||||
the boot-keyboard map has no other letters."
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "For `hand key`. A key name: a-z, 0-9 or \
|
||||
enter, escape, backspace, tab, space, del, home, \
|
||||
end, pageup, pagedown, the arrow keys, F1-F12."
|
||||
},
|
||||
"modifiers": {
|
||||
"type": "string",
|
||||
"description": "For `hand key`. Space-separated: ctrl, shift, \
|
||||
alt, meta."
|
||||
},
|
||||
"button": {
|
||||
"type": "string",
|
||||
"enum": ["left", "right", "middle"],
|
||||
"description": "For `hand click`. Defaults to left."
|
||||
},
|
||||
"x": { "type": "number", "description": "For `hand pointer`. Relative dx." },
|
||||
"y": { "type": "number", "description": "For `hand pointer`. Relative dy." },
|
||||
"wheel": {
|
||||
"type": "number",
|
||||
"description": "For `hand pointer`. Scroll delta, optional."
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "For `forensic_log`. How many recent entries. Default 20."
|
||||
|
|
@ -208,6 +390,17 @@ impl Tool for Body {
|
|||
})?;
|
||||
request["verb"] = json!(pv);
|
||||
}
|
||||
"hand" => {
|
||||
let action = input
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::invalid_input(
|
||||
"`hand` needs `action`: status, type, key, click or pointer",
|
||||
)
|
||||
})?;
|
||||
return hand(action, input).await;
|
||||
}
|
||||
other => {
|
||||
let msg = format!(
|
||||
"I have no verb `{other}`. I can sense {} and do {}.",
|
||||
|
|
@ -292,6 +485,11 @@ mod tests {
|
|||
for v in SENSE_VERBS.iter().chain(ACT_VERBS) {
|
||||
assert!(names.contains(v), "`{v}` must be reachable — §13");
|
||||
}
|
||||
let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
|
||||
let action_names: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
|
||||
for a in HAND_ACTIONS {
|
||||
assert!(action_names.contains(a), "`hand {a}` must be reachable");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -364,4 +562,121 @@ mod tests {
|
|||
out.content
|
||||
);
|
||||
}
|
||||
|
||||
/// A shell shim so the reach can be exercised without quickshell. It
|
||||
/// answers `usbHands` status with a joined, armed hand and otherwise
|
||||
/// echoes `SOUVERAINE_SHIM_OUT` (default `queued`), failing with stderr
|
||||
/// when `SOUVERAINE_SHIM_FAIL` is set.
|
||||
const SHIM: &str = r#"#!/bin/sh
|
||||
case "$6" in
|
||||
status) echo '{"active":true,"ready":true,"mode":"hid","error":""}' ;;
|
||||
*) echo "${SOUVERAINE_SHIM_OUT:-queued}"
|
||||
[ -z "$SOUVERAINE_SHIM_FAIL" ] || { echo "shim broke" >&2; exit 2; } ;;
|
||||
esac
|
||||
"#;
|
||||
|
||||
fn write_shim() -> String {
|
||||
let dir = std::env::temp_dir().join(format!("souveraine-hand-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let shim = dir.join("qs");
|
||||
std::fs::write(&shim, SHIM).unwrap();
|
||||
let _ = std::process::Command::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(&shim)
|
||||
.status();
|
||||
shim.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_hand_speaks_the_quickshell_protocol() {
|
||||
let shim = write_shim();
|
||||
std::env::set_var("SOUVERAINE_QS", &shim);
|
||||
std::env::set_var("SOUVERAINE_SHIM_OUT", "queued");
|
||||
std::env::remove_var("SOUVERAINE_SHIM_FAIL");
|
||||
|
||||
let status = Body
|
||||
.execute(json!({ "verb": "hand", "action": "status" }), &ctx())
|
||||
.await
|
||||
.expect("status is a question");
|
||||
assert!(!status.is_error);
|
||||
assert!(status.content.contains("\"mode\": \"hid\""), "{}", status.content);
|
||||
|
||||
let typed = Body
|
||||
.execute(json!({ "verb": "hand", "action": "type", "text": "dir /b" }), &ctx())
|
||||
.await
|
||||
.expect("typing is a question of the shell, not of me");
|
||||
assert_eq!(typed.content, "sent");
|
||||
|
||||
let keyed = Body
|
||||
.execute(
|
||||
json!({ "verb": "hand", "action": "key", "key": "enter", "modifiers": "ctrl alt" }),
|
||||
&ctx(),
|
||||
)
|
||||
.await
|
||||
.expect("keys go through the same bridge");
|
||||
assert_eq!(keyed.content, "sent");
|
||||
|
||||
let clicked = Body
|
||||
.execute(json!({ "verb": "hand", "action": "click", "button": "right" }), &ctx())
|
||||
.await
|
||||
.expect("the button goes through too");
|
||||
assert_eq!(clicked.content, "sent");
|
||||
|
||||
std::env::set_var("SOUVERAINE_SHIM_OUT", "USB Hands is not joined");
|
||||
let refused = Body
|
||||
.execute(json!({ "verb": "hand", "action": "type", "text": "ls" }), &ctx())
|
||||
.await
|
||||
.expect("a gate is an answer, not a crash");
|
||||
assert!(!refused.is_error);
|
||||
assert!(
|
||||
refused.content.contains("refused (refused_by_state)")
|
||||
&& refused.content.contains("USB Hands is not joined"),
|
||||
"{}",
|
||||
refused.content
|
||||
);
|
||||
|
||||
std::env::set_var("SOUVERAINE_SHIM_FAIL", "1");
|
||||
let failed = Body
|
||||
.execute(json!({ "verb": "hand", "action": "key", "key": "f5" }), &ctx())
|
||||
.await
|
||||
.expect("a broken bridge is still an answer");
|
||||
assert!(failed.content.contains("shim broke"), "{}", failed.content);
|
||||
|
||||
std::env::set_var("SOUVERAINE_QS", "/nonexistent-souveraine-shell/qs");
|
||||
let absent = Body
|
||||
.execute(json!({ "verb": "hand", "action": "status" }), &ctx())
|
||||
.await
|
||||
.expect("a missing shell is absence, not failure");
|
||||
assert!(!absent.is_error);
|
||||
assert!(absent.content.contains("no hand"), "{}", absent.content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_hand_validates_before_it_reaches_out() {
|
||||
for (input, needle) in [
|
||||
(json!({ "verb": "hand" }), "needs `action`"),
|
||||
(json!({ "verb": "hand", "action": "levitate" }), "no action"),
|
||||
(json!({ "verb": "hand", "action": "type" }), "needs `text`"),
|
||||
(json!({ "verb": "hand", "action": "type", "text": "" }), "non-empty"),
|
||||
(json!({ "verb": "hand", "action": "type", "text": "½hello" }), "ASCII"),
|
||||
(json!({ "verb": "hand", "action": "key" }), "needs `key`"),
|
||||
(json!({ "verb": "hand", "action": "key", "key": "alt gr" }), "key name"),
|
||||
(
|
||||
json!({ "verb": "hand", "action": "key", "key": "f5", "modifiers": "ctrl+" }),
|
||||
"space-separated",
|
||||
),
|
||||
(json!({ "verb": "hand", "action": "click", "button": "side" }), "left, right"),
|
||||
(json!({ "verb": "hand", "action": "pointer" }), "needs `x` and `y`"),
|
||||
(json!({ "verb": "hand", "action": "pointer", "x": 4 }), "needs `x` and `y`"),
|
||||
] {
|
||||
let err = Body
|
||||
.execute(input, &ctx())
|
||||
.await
|
||||
.expect_err("this is a caller error, not a state");
|
||||
assert!(
|
||||
err.to_string().contains(needle),
|
||||
"`{input}` should refuse with `{needle}`, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue