test: rehearse P2 passkey approvals, injected fetch, and SSH push
This commit is contained in:
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user