test: rehearse P2 passkey approvals, injected fetch, and SSH push

This commit is contained in:
2026-11-07 01:34:45 +00:00
parent 4dd6161091
commit ce9418f2e4
6 changed files with 22967 additions and 4 deletions
+1 -1
View File
@@ -44397,7 +44397,7 @@ function environment(cwd = "/", conversation) {
}
};
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation);
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation === void 0 ? void 0 : String(conversation));
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result;
while (true) {
+2 -2
View File
@@ -10,7 +10,7 @@ const path = (cwd: string, input: string) => {
}
return '/' + parts.join('/');
};
export function environment(cwd = '/', conversation?: string) {
export function environment(cwd = '/', conversation?: string | number) {
const call = (kind: string, p: string, bytes: number[] = []) => ops.op_file(kind, path(cwd, p), bytes);
const env: any = {
id: 'world', cwd,
@@ -87,7 +87,7 @@ export function environment(cwd = '/', conversation?: string) {
}
};
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation);
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation === undefined ? undefined : String(conversation));
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result;
while (true) {
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
// P2 rehearsal: real bash tools pause for two passkey approvals and resume durably.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText } from '@earendil-works/pi-ai/providers/faux';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { environment } from './environment';
import { openStorage } from './sqlite';
import { CredentialRequests, startRequests } from './credentials';
const models = createModels();
const faux = fauxProvider({ tokensPerSecond: 100000 });
models.setProvider(faux.provider);
const bash = (command: string, id: string) => fauxAssistantMessage([fauxToolCall('bash', { command }, { id })], { stopReason: 'toolUse' });
const curl = `curl -i http://127.0.0.1:${globalThis.p2Http}/whoami`;
const push = 'cd /repo && git push origin main';
faux.setResponses([
bash(curl, 'curl-denied'), fauxAssistantMessage([fauxText('Wait for HTTP approval.')]),
bash(curl, 'curl-approved'), bash(push, 'ssh-denied'), fauxAssistantMessage([fauxText('Wait for SSH approval.')]),
bash(push, 'ssh-approved'), fauxAssistantMessage([fauxText('P2 complete.')]),
]);
const registry = createRegistry();
registry.install(CodingTools); registry.install(CredentialRequests);
const harness = await Harness.open(await openStorage(globalThis.storagePath), {
models, registry, env: (target: any) => environment('/', target.conversationId),
}, context);
const root = await harness.root(context, { agent: {
model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CodingTools, CredentialRequests], cwd: '/',
} });
const stop = await startRequests(harness, context);
harness.resume();
await (await root.submit({ type: 'input', content: 'Rehearse P2', requestId: 'p2' }, context)).wait(context);
await Deno.core.ops.op_file('write', '/p2-http-idle', []);
const watch = await root.watch(context);
let sshIdle = false;
await new Promise<void>((resolve, reject) => {
const check = async (event: any) => {
if (event.kind !== 'value') return;
const transcript = JSON.stringify(event.value.entries);
if (!sshIdle && transcript.includes('Wait for SSH approval.')) {
sshIdle = true;
await harness.waitForIdle(context);
await Deno.core.ops.op_file('write', '/p2-ssh-idle', []);
}
const approvals = event.value.entries.filter((entry: any) => JSON.stringify(entry).includes('Your request for'));
if (approvals.length > 2) reject(new Error('Duplicate approval wake-up'));
if (transcript.includes('P2 complete.')) resolve();
};
watch.start(check); check({ kind: 'value', value: watch.value });
});
await harness.waitForIdle(context);
const transcript = JSON.stringify(watch.value.entries);
for (const expected of ['403', 'demo-api', 'demo-ssh', 'Bearer accepted', 'P2 complete.']) {
if (!transcript.includes(expected)) throw new Error(`Missing rehearsal result: ${expected}`);
}
if (watch.value.entries.filter((entry: any) => JSON.stringify(entry).includes('Your request for')).length !== 2) {
throw new Error('Expected exactly two approval inputs');
}
await Deno.core.ops.op_file('write', '/p2-transcript', [...new TextEncoder().encode(transcript)]);
await watch.stop(); await stop(); await harness.close(context);
+1 -1
View File
@@ -21291,7 +21291,7 @@ function environment(cwd = "/", conversation) {
}
};
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation);
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation === void 0 ? void 0 : String(conversation));
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result2;
while (true) {
+523
View File
@@ -0,0 +1,523 @@
//! P2 exit rehearsal: native host pages, software passkey, durable bash tools, and real SSH Git push.
use anyhow::{Result, ensure};
use base64::{Engine, engine::general_purpose::STANDARD};
use sausage::{
api::{Api, Listener, Options},
cred::{Cred, entry::Envelope, webauthn::Challenge},
world::World,
};
use serde_json::{Value, json};
use std::{
io::{Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::Arc,
time::{Duration, Instant},
};
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse};
fn command(cwd: &Path, args: &[&str]) -> Result<()> {
ensure!(
Command::new(args[0])
.args(&args[1..])
.current_dir(cwd)
.stdout(Stdio::null())
.status()?
.success(),
"fixture command failed"
);
Ok(())
}
struct Sshd(Child);
impl Drop for Sshd {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
struct Browser {
address: SocketAddr,
origin: String,
auth: WebauthnAuthenticator<SoftPasskey>,
}
impl Browser {
fn raw(&self, method: &str, path: &str, body: Value) -> Result<String> {
let bytes = serde_json::to_vec(&body)?;
let mut connection = TcpStream::connect(self.address)?;
connection.set_read_timeout(Some(Duration::from_secs(10)))?;
write!(
connection,
"{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
bytes.len()
)?;
connection.write_all(&bytes)?;
let mut response = String::new();
connection.read_to_string(&mut response)?;
let error = serde_json::from_str::<Value>(
response
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or(""),
)
.ok();
ensure!(
response.starts_with("HTTP/1.1 200"),
"host page request failed: {path}, kind {}",
error
.as_ref()
.and_then(|value| value["error"].as_str())
.unwrap_or("invalid-response")
);
Ok(response
.split_once("\r\n\r\n")
.ok_or_else(|| anyhow::anyhow!("invalid HTTP response"))?
.1
.to_owned())
}
fn json(&self, method: &str, path: &str, body: Value) -> Result<Value> {
Ok(serde_json::from_str(&self.raw(method, path, body)?)?)
}
fn assertion(&mut self, path: &str, body: Value) -> Result<Value> {
let start: Challenge<RequestChallengeResponse> =
serde_json::from_value(self.json("POST", path, body)?)?;
let assertion = self
.auth
.do_authentication(url::Url::parse(&self.origin)?, start.options)?;
Ok(json!({"challenge_id":start.id,"assertion":assertion}))
}
fn approve(&mut self, id: &str) -> Result<()> {
ensure!(
self.raw("GET", &format!("/approve/{id}"), Value::Null)?
.contains("/app.js"),
"approval page missing"
);
let path = format!("/cred/requests/{id}");
let gate = self.assertion(&format!("{path}/challenge"), json!({"decision":"approve"}))?;
self.json("POST", &format!("{path}/decide"), gate)?;
Ok(())
}
fn create(&mut self, config: Value) -> Result<Value> {
let name = config["name"].as_str().unwrap().to_owned();
let request = self.json("POST", "/cred/requests", json!({"kind":"edit","key":format!("setup-{name}"),"request":{"item":null,"base_version":null,"change":[{"op":"create_item","config":config}]}}))?;
self.approve(request["request"].as_str().unwrap())?;
let items = self.json("GET", "/cred/items", Value::Null)?;
Ok(items
.as_array()
.unwrap()
.iter()
.find(|item| item["name"] == name)
.unwrap()
.clone())
}
async fn enter(&mut self, item: &Value, secret: &str) -> Result<()> {
let path = format!(
"/cred/items/{}/fields/{}",
item["id"].as_str().unwrap(),
item["fields"][0]["field_id"].as_str().unwrap()
);
ensure!(
self.raw(
"GET",
&format!(
"/entry/{}/{}",
item["id"].as_str().unwrap(),
item["fields"][0]["field_id"].as_str().unwrap()
),
Value::Null
)?
.contains("/app.js"),
"secret-entry page missing"
);
let public = self.json("GET", "/cred/entry/key", Value::Null)?;
let script = self.raw("GET", "/app.js", Value::Null)?;
let mut runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
Arc::new(deno_web::BlobStore::default()),
None,
false,
Default::default(),
),
deno_crypto::deno_crypto::init(None),
],
..Default::default()
});
runtime.execute_script("browser.js", "globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
runtime.execute_script("app.js", script)?;
runtime.execute_script("entry.js", format!("encrypt({},{}).then(value => globalThis.envelope = JSON.stringify(value.envelope))", public["public_key"], serde_json::to_string(secret)?))?;
runtime.run_event_loop(Default::default()).await?;
let result = runtime.execute_script("result.js", "envelope")?;
let text = {
deno_core::scope!(scope, &mut runtime);
deno_core::v8::Local::new(scope, result).to_rust_string_lossy(scope)
};
let envelope: Envelope = serde_json::from_str(&text)?;
let mut gate = self.assertion(
&format!("{path}/challenge"),
json!({"ciphertext_sha256":envelope.sha256()}),
)?;
gate["envelope"] = serde_json::to_value(envelope)?;
self.json("POST", &path, gate)?;
Ok(())
}
}
fn components() -> Result<PathBuf> {
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("p2-userland");
if !userland.join("cwd-init/init.o").is_file() {
command(&userland, &["nu", "--no-config-file", "cwd-init/build.nu"])?;
}
let status = Command::new("cargo")
.current_dir(&userland)
.args([
"build",
"--release",
"--locked",
"--target",
"wasm32-wasip2",
"-j",
"2",
"-p",
"git",
"-p",
"curl",
])
.arg("--target-dir")
.arg(&target)
.status()?;
ensure!(status.success(), "guest build failed");
let release = target.join("wasm32-wasip2/release");
if !release.join("sh.wasm").is_file() {
ensure!(
Command::new("nu")
.arg("brush/build.nu")
.env("CARGO_TARGET_DIR", &target)
.current_dir(&userland)
.status()?
.success(),
"shell build failed"
);
std::fs::copy(release.join("brush.wasm"), release.join("sh.wasm"))?;
}
Ok(release)
}
fn no_secrets(path: &Path, secrets: &[&[u8]]) -> Result<()> {
for entry in std::fs::read_dir(path)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
no_secrets(&entry.path(), secrets)?;
} else {
let bytes = std::fs::read(entry.path())?;
for secret in secrets {
ensure!(
!bytes.windows(secret.len()).any(|value| value == *secret),
"secret escaped into a world file"
);
}
}
}
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn request_approve_fetch_and_ssh_push() -> Result<()> {
let components = components()?;
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
let root = dir.path().join("world");
std::fs::create_dir_all(root.join("bin"))?;
for (source, destination) in [
("git.wasm", "git.wasm"),
("curl.wasm", "curl.wasm"),
("sh.wasm", "sh.wasm"),
] {
std::fs::copy(components.join(source), root.join("bin").join(destination))?;
}
let token = format!("p2-private-{}", uuid::Uuid::new_v4());
let mut seed = [0; 32];
openssl::rand::rand_bytes(&mut seed)?;
let key = STANDARD.encode(seed);
let public = ed25519_dalek::SigningKey::from_bytes(&seed)
.verifying_key()
.to_bytes();
let mut blob = Vec::from(11u32.to_be_bytes());
blob.extend_from_slice(b"ssh-ed25519");
blob.extend_from_slice(&32u32.to_be_bytes());
blob.extend_from_slice(&public);
let fixture = dir.path().join("sshd");
std::fs::create_dir_all(&fixture)?;
std::fs::write(
fixture.join("authorized_keys"),
format!("ssh-ed25519 {}\n", STANDARD.encode(blob)),
)?;
command(
&fixture,
&[
"ssh-keygen",
"-q",
"-t",
"ed25519",
"-N",
"",
"-f",
"host_key",
],
)?;
let reserve = TcpListener::bind("127.0.0.1:0")?;
let ssh_port = reserve.local_addr()?.port();
drop(reserve);
let user = std::env::var("USER")?;
std::fs::write(
fixture.join("config"),
format!(
"Port {ssh_port}\nListenAddress 127.0.0.1\nHostKey {}\nPidFile {}\nAuthorizedKeysFile {}\nStrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers {user}\n",
fixture.join("host_key").display(),
fixture.join("pid").display(),
fixture.join("authorized_keys").display()
),
)?;
let ssh_binary = std::env::split_paths(&std::env::var_os("PATH").unwrap())
.map(|path| path.join("sshd"))
.find(|path| path.is_file())
.unwrap()
.canonicalize()?;
let _sshd = Sshd(
Command::new(ssh_binary)
.args(["-D", "-e", "-f"])
.arg(fixture.join("config"))
.stdout(Stdio::null())
.stderr(std::fs::File::create(fixture.join("log"))?)
.spawn()?,
);
let deadline = Instant::now() + Duration::from_secs(10);
while TcpStream::connect(("127.0.0.1", ssh_port)).is_err() {
ensure!(Instant::now() < deadline, "sshd did not start");
tokio::task::yield_now().await;
}
std::fs::create_dir_all(root.join(".ssh"))?;
let host_public = std::fs::read_to_string(fixture.join("host_key.pub"))?;
std::fs::write(
root.join(".ssh/known_hosts"),
format!("[127.0.0.1]:{ssh_port} {host_public}"),
)?;
let repo = root.join("repo");
std::fs::create_dir_all(&repo)?;
command(&repo, &["git", "init", "-b", "main"])?;
std::fs::write(repo.join("demo"), "P2 push\n")?;
command(&repo, &["git", "add", "demo"])?;
command(
&repo,
&[
"git",
"-c",
"user.name=P2",
"-c",
"user.email=p2@example.com",
"commit",
"-m",
"rehearsal",
],
)?;
command(&fixture, &["git", "init", "--bare", "destination.git"])?;
let url = format!(
"ssh://{user}@127.0.0.1:{ssh_port}{}/destination.git",
fixture.display()
);
command(&repo, &["git", "remote", "add", "origin", &url])?;
let server = Arc::new(
tiny_http::Server::http("127.0.0.1:0")
.map_err(|_| anyhow::anyhow!("HTTP fixture bind failed"))?,
);
let http_port = server.server_addr().to_ip().unwrap().port();
let serving = server.clone();
let expected = format!("Bearer {token}");
let thread = std::thread::spawn(move || {
if let Ok(request) = serving.recv() {
let valid = request.headers().iter().any(|header| {
header.field.equiv("Authorization") && header.value.as_str() == expected
});
request
.respond(tiny_http::Response::from_string(if valid {
"Bearer accepted"
} else {
"missing bearer"
}))
.unwrap();
valid
} else {
false
}
});
let reserve = TcpListener::bind("127.0.0.1:0")?;
let address = reserve.local_addr()?;
drop(reserve);
let origin = format!("http://localhost:{}", address.port());
let cred = Arc::new(Cred::open(&world.host_dir)?);
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
"localhost",
&origin,
)?);
let runtime = sausage::process::Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
sausage::process::default_script(),
Some(api.clone()),
)?;
let listener = Listener::start_with_api(
api,
Options {
api_listen: address,
api_rp_id: "localhost".into(),
api_origin: origin.clone(),
},
)?;
runtime
.network_policy()
.deny_host_port(listener.address().port());
let mut browser = Browser {
address: listener.address(),
origin,
auth: WebauthnAuthenticator::new(SoftPasskey::new(true)),
};
ensure!(
browser
.raw("GET", "/register", Value::Null)?
.contains("/app.js"),
"registration page missing"
);
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(browser.json(
"POST",
"/cred/passkeys/register/start",
json!({"label":"Surma rehearsal"}),
)?)?;
let registration = browser
.auth
.do_registration(url::Url::parse(&browser.origin)?, start.options)?;
browser.json(
"POST",
"/cred/passkeys/register/finish",
json!({"challenge_id":start.id,"response":registration}),
)?;
let http_item = browser.create(json!({"name":"demo-api","rules":[{"scheme":"http","hosts":["127.0.0.1"],"port":http_port,"path_prefixes":["/"]}],"transform_js":sausage::cred::summary::script("bearer")?,"fields":[{"name":"token","field_type":"text","public":false}]}))?;
browser.enter(&http_item, &token).await?;
let ssh_item = browser.create(json!({"name":"demo-ssh","rules":[{"scheme":"ssh","port":22,"hosts":["127.0.0.1"],"path_prefixes":["/"]}],"transform_js":"","fields":[{"name":"key","field_type":"ssh_private_key","key_type":"ed25519-raw","public":false}]}))?;
browser.enter(&ssh_item, &key).await?;
let source = format!(
"globalThis.p2Http = {http_port};\n{}",
include_str!("../js/p2-test-bundle.js")
);
let approve = async {
for item in [&http_item, &ssh_item] {
let deadline = Instant::now() + Duration::from_secs(90);
let request = loop {
let requests = browser.json("GET", "/cred/requests?state=pending", Value::Null)?;
if let Some(request) = requests.as_array().unwrap().iter().find(|request| {
request["payload"]["items"]
.as_array()
.is_some_and(|items| items.contains(&item["id"]))
}) {
break request["id"].as_str().unwrap().to_owned();
}
ensure!(
Instant::now() < deadline,
"conversation did not file a request"
);
tokio::task::yield_now().await;
};
let marker = if item["name"] == "demo-api" {
"p2-http-idle"
} else {
"p2-ssh-idle"
};
while !root.join(marker).exists() {
ensure!(
Instant::now() < deadline,
"conversation did not finish its turn"
);
tokio::task::yield_now().await;
}
browser.approve(&request)?;
}
Ok::<_, anyhow::Error>(())
};
let (harness, approvals) = tokio::time::timeout(Duration::from_secs(180), async {
tokio::try_join!(
sausage::harness::run_module_with_credentials(
world,
Arc::new(runtime),
cred.clone(),
&source
),
approve
)
})
.await??;
let _ = (harness, approvals);
server.unblock();
ensure!(
thread.join().unwrap(),
"server did not receive the bearer token"
);
let local = Command::new("git")
.args(["rev-parse", "main"])
.current_dir(&repo)
.output()?;
let remote = Command::new("git")
.args(["rev-parse", "main"])
.current_dir(fixture.join("destination.git"))
.output()?;
ensure!(
local.status.success() && remote.status.success() && local.stdout == remote.stdout,
"SSH push did not update remote main"
);
let requests = browser.json("GET", "/cred/requests", Value::Null)?;
for item in [&http_item, &ssh_item] {
ensure!(
requests
.as_array()
.unwrap()
.iter()
.filter(|request| request["payload"]["items"]
.as_array()
.is_some_and(|items| items.contains(&item["id"])))
.count()
== 1,
"duplicate automatic item request"
);
}
no_secrets(&root, &[token.as_bytes(), key.as_bytes(), &seed])?;
for file in ["harness.sqlite", "harness.sqlite-wal"] {
if let Ok(bytes) = std::fs::read(dir.path().join("host").join(file)) {
for secret in [token.as_bytes(), key.as_bytes(), &seed] {
ensure!(
!bytes.windows(secret.len()).any(|value| value == secret),
"secret escaped into harness storage"
);
}
}
}
let audit = cred.audit_list()?;
ensure!(
audit
.iter()
.any(|entry| entry.event == "fetch.use"
&& entry.item.as_deref() == http_item["id"].as_str()),
"HTTP use missing from audit"
);
ensure!(
audit
.iter()
.any(|entry| entry.event == "sign" && entry.item.as_deref() == ssh_item["id"].as_str()),
"SSH use missing from audit"
);
Ok(())
}