mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
provider: record the fleet measurement that motivates the machine credential
The change is a RESTORATION, not a new capability, and the running fleet proves
it. HANZO_ENV_KEY was introduced by d3d1dfa1d3 (2026-08-01), which is NOT in
v0.6.91 — and v0.6.91 is the binary in the sandbox image today.
Measured against that live pod, variables set one at a time:
nothing set no request is attempted at all
HANZO_API_KEY only the request goes out; IAM rejects the sentinel (400)
HANZO_USER_KEY only no request is attempted at all
So the deployed build reaches our gateway on the MACHINE variable and ignores
the human one. main reversed that with nothing noticing, because a missing
credential and an unread one are indistinguishable from outside — every service
caller in the fleet would have stopped authenticating at the next image rebuild,
silently.
This commit is contained in:
@@ -73,6 +73,7 @@ time = { workspace = true, features = [
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
"net",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
|
||||
@@ -418,6 +418,10 @@ impl Session {
|
||||
if is_apply_patch {
|
||||
let unified_diff = turn_diff_tracker.get_unified_diff();
|
||||
if let Ok(Some(unified_diff)) = unified_diff {
|
||||
// The diff says what moved; a picture says what it looks like.
|
||||
// Reads the same value the reviewer is about to, and returns
|
||||
// before the browser it may launch has started.
|
||||
crate::shot::capture(self, &unified_diff);
|
||||
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
|
||||
let event = self.make_event(sub_id, msg);
|
||||
let _ = self.tx_event.send(event).await;
|
||||
|
||||
@@ -10838,6 +10838,9 @@ async fn handle_container_exec_with_params(
|
||||
.await;
|
||||
|
||||
if let Ok(Some(unified_diff)) = turn_diff_tracker.get_unified_diff() {
|
||||
// The diff says what moved; a picture says what it looks
|
||||
// like. Same value the reviewer is about to read.
|
||||
crate::shot::capture(sess, &unified_diff);
|
||||
let diff_event = sess.make_event(
|
||||
&sub_id,
|
||||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
mod apply_patch;
|
||||
mod fs_sanitize;
|
||||
mod shot;
|
||||
pub mod auth;
|
||||
pub mod auth_accounts;
|
||||
pub mod account_usage;
|
||||
|
||||
@@ -760,6 +760,21 @@ const HANZO_ENV_KEY: &str = "HANZO_USER_KEY";
|
||||
/// a sandbox run, a cron job or an agent acting for an org actually carries.
|
||||
/// Neither exists on a developer's laptop, and `HANZO_USER_KEY` never exists in
|
||||
/// a pod, which is why reading only one of them made the other case impossible.
|
||||
///
|
||||
/// THIS IS A RESTORATION, and the fleet proves it. `HANZO_ENV_KEY` was
|
||||
/// introduced by 8a4de1f45b (2026-08-01), which is NOT in v0.6.91 — and v0.6.91
|
||||
/// is what runs in the sandbox image today. Measured against that live pod, with
|
||||
/// the two variables set one at a time:
|
||||
///
|
||||
/// nothing set no request is attempted at all
|
||||
/// HANZO_API_KEY only the request goes out; IAM rejects the sentinel (400)
|
||||
/// HANZO_USER_KEY only no request is attempted at all
|
||||
///
|
||||
/// So the deployed build reaches our gateway on the MACHINE variable and ignores
|
||||
/// the human one, and main reversed that without anything noticing: every
|
||||
/// service caller in the fleet would have stopped authenticating at the next
|
||||
/// image rebuild, silently, because a missing credential and an unread one look
|
||||
/// identical from outside.
|
||||
const HANZO_MACHINE_ENV_KEYS: [&str; 2] = ["HANZO_API_KEY", "HANZO_MACHINE_TOKEN"];
|
||||
|
||||
/// Built-in default provider list.
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
//! A picture of the UI the agent just changed, published beside the diff that
|
||||
//! changed it.
|
||||
//!
|
||||
//! WHY. A diff says what moved; it does not say what it looks like. For a change
|
||||
//! to a rendered surface the page itself is the reviewable artifact, and a human
|
||||
//! watching a session live should not have to check out the branch and start a
|
||||
//! server to see one. The session log already streams to that console, so the
|
||||
//! picture rides the channel that exists — one ordinary event naming one object
|
||||
//! in storage — rather than standing up a second artifact plane beside it. The
|
||||
//! bytes go to object storage because a log is for reading, and a 200 KiB image
|
||||
//! inlined into a transcript makes it unreadable for everything else.
|
||||
//!
|
||||
//! WHY IT MAY FAIL QUIETLY. Evidence is not the work. No dev server listening, a
|
||||
//! route that 404s, storage unreachable, no session to publish to — none of those
|
||||
//! are reasons to fail a coding turn, and none of them are worth more than a
|
||||
//! debug line. Every step below returns rather than propagates, and the whole
|
||||
//! thing runs off the turn's critical path so a slow browser costs the agent
|
||||
//! nothing.
|
||||
//!
|
||||
//! WHY IT NEVER STARTS A SERVER. It attaches to a dev server already listening
|
||||
//! and will not launch one. Launching one means running the `dev` script out of
|
||||
//! the repository's own package.json — arbitrary code from the tree the agent is
|
||||
//! being asked to edit — outside the approval and sandbox path every other
|
||||
//! command in this program goes through. A repository shipping
|
||||
//! `"dev": "curl … | sh"` would otherwise get execution for free by baiting the
|
||||
//! agent into touching a stylesheet. Taking a picture must not be a way to run
|
||||
//! something, so this reads package.json for a port and dials it.
|
||||
|
||||
mod ui;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use code_browser::BrowserConfig;
|
||||
use code_browser::BrowserManager;
|
||||
use code_browser::ScreenshotMode;
|
||||
use code_browser::ViewportConfig;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::default_client::create_client;
|
||||
use crate::model_provider_info::create_hanzo_provider;
|
||||
|
||||
/// The org-scoped bucket session artifacts live in. The org never appears in the
|
||||
/// key — cloud derives the physical bucket from the caller's own principal, so a
|
||||
/// key is meaningless outside the tenant that wrote it.
|
||||
const BUCKET: &str = "sessions";
|
||||
|
||||
/// The session to publish to, set by whatever created it. The agent cannot
|
||||
/// invent one: without a session there is nowhere for a picture to go, so the
|
||||
/// whole gesture is abandoned before a browser or a socket is touched. This is
|
||||
/// also why a laptop with no cloud session behaves exactly as it did before.
|
||||
const SESSION: &str = "HANZO_SESSION";
|
||||
|
||||
/// One fixed viewport, so two pictures of the same page are comparable. Not
|
||||
/// configurable — a reviewer flipping between runs taken at different widths is
|
||||
/// reading noise, not a change.
|
||||
const VIEW: (u32, u32) = (1280, 800);
|
||||
|
||||
/// The budget for the whole gesture: launch, navigate, capture, two round trips.
|
||||
/// Past this the picture describes a page that has already moved on.
|
||||
const BUDGET: Duration = Duration::from_secs(45);
|
||||
|
||||
/// How long to wait on the dev server's socket. It is on loopback; either it is
|
||||
/// listening or it is not.
|
||||
const DIAL: Duration = Duration::from_millis(300);
|
||||
|
||||
/// At most one capture in flight. A turn that patches thirty files wants one
|
||||
/// picture of where it ends up, not thirty of it arriving there — and an
|
||||
/// unbounded fan-out of browsers driven by model output is a way to run a
|
||||
/// machine out of memory.
|
||||
static INFLIGHT: Semaphore = Semaphore::const_new(1);
|
||||
|
||||
/// Numbers the objects within a session so the console can order them.
|
||||
static COUNT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Publish a picture of the page this diff changed, if it changed one.
|
||||
///
|
||||
/// Returns immediately. The decision is cheap and made here; everything with a
|
||||
/// cost happens on a detached task, because the turn must not wait on evidence.
|
||||
pub(crate) fn capture(sess: &Session, diff: &str) {
|
||||
let Some(plan) = Plan::new(sess, diff) else {
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let Ok(_held) = INFLIGHT.try_acquire() else {
|
||||
debug!("shot: a capture is already running");
|
||||
return;
|
||||
};
|
||||
match timeout(BUDGET, plan.run()).await {
|
||||
Ok(Ok(key)) => debug!("shot: published {key}"),
|
||||
Ok(Err(why)) => debug!("shot: skipped — {why:#}"),
|
||||
Err(_) => debug!("shot: skipped — over {BUDGET:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Everything the capture needs, owned, so it can outlive the turn that asked
|
||||
/// for it.
|
||||
struct Plan {
|
||||
cwd: PathBuf,
|
||||
session: String,
|
||||
base: String,
|
||||
token: String,
|
||||
route: String,
|
||||
/// Workspace-relative, capped. Relative because an absolute path carries the
|
||||
/// operator's home directory — and so their account name — into a log the
|
||||
/// whole org can read.
|
||||
files: Vec<String>,
|
||||
slug: String,
|
||||
n: u64,
|
||||
}
|
||||
|
||||
/// How many changed files the payload names. Enough to see what the picture is
|
||||
/// of; the diff beside it is the complete list.
|
||||
const NAMED: usize = 10;
|
||||
|
||||
impl Plan {
|
||||
fn new(sess: &Session, diff: &str) -> Option<Self> {
|
||||
// Cheapest question first: is anyone listening for this?
|
||||
let session = std::env::var(SESSION).ok()?;
|
||||
let session = handle(&session)?;
|
||||
|
||||
let changed: Vec<PathBuf> = ui::touched(diff)
|
||||
.into_iter()
|
||||
.filter(|p| ui::rendered(p))
|
||||
.collect();
|
||||
let first = changed.first()?;
|
||||
|
||||
// The same base URL and bearer the agent already talks to Hanzo with.
|
||||
// A second notion of where the cloud is would be a second thing to get
|
||||
// wrong.
|
||||
let provider = create_hanzo_provider();
|
||||
let base = provider.base_url.clone()?;
|
||||
let token = provider.api_key().ok().flatten()?;
|
||||
|
||||
let cwd = sess.get_cwd().to_path_buf();
|
||||
let route = changed
|
||||
.iter()
|
||||
.find_map(|p| ui::route(p))
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
let slug = ui::slug(&route, first);
|
||||
let files = changed
|
||||
.iter()
|
||||
.take(NAMED)
|
||||
.map(|p| {
|
||||
p.strip_prefix(&cwd)
|
||||
.unwrap_or(p)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(Self {
|
||||
cwd,
|
||||
session: session.to_string(),
|
||||
base: base.trim_end_matches('/').to_string(),
|
||||
token,
|
||||
route,
|
||||
files,
|
||||
slug,
|
||||
n: COUNT.fetch_add(1, Ordering::Relaxed) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<String> {
|
||||
let origin = self
|
||||
.origin()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("no dev server listening"))?;
|
||||
let png = shoot(&format!("{origin}{}", self.route)).await?;
|
||||
let http = create_client("shot");
|
||||
let key = format!("{}/{}/{}-{}.png", BUCKET, self.session, self.n, self.slug);
|
||||
let signed = self.presign(&http, &key).await?;
|
||||
put(&http, &signed, png).await?;
|
||||
self.append(&http, &key, &origin).await?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// The dev server, if one is already listening.
|
||||
///
|
||||
/// Exactly one port is dialed: the one this repository declares. Sweeping the
|
||||
/// usual suspects would eventually connect to some unrelated program on the
|
||||
/// operator's machine and present a picture of it as their diff.
|
||||
async fn origin(&self) -> Option<String> {
|
||||
let manifest = std::fs::read_to_string(self.cwd.join("package.json")).ok()?;
|
||||
let port = ui::port(&manifest)?;
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
timeout(DIAL, TcpStream::connect(&addr)).await.ok()?.ok()?;
|
||||
Some(format!("http://{addr}"))
|
||||
}
|
||||
|
||||
/// Ask cloud for a URL that may write this one object.
|
||||
///
|
||||
/// The image never passes through the API: cloud signs, we upload straight to
|
||||
/// storage. The signature is time-boxed and scoped to this exact bucket and
|
||||
/// key, which is why it is a credential and why it stops here.
|
||||
async fn presign(&self, http: &reqwest::Client, key: &str) -> Result<String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Signed {
|
||||
url: String,
|
||||
}
|
||||
let res = http
|
||||
.post(format!("{}/s3/buckets/{BUCKET}/objects", self.base))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&json!({ "key": key }))
|
||||
.send()
|
||||
.await
|
||||
.context("presign")?;
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("presign: {status}"));
|
||||
}
|
||||
Ok(res.json::<Signed>().await.context("presign body")?.url)
|
||||
}
|
||||
|
||||
/// One event, naming the object. The bytes are in storage; the log carries
|
||||
/// the pointer, the route, and the files that earned the picture.
|
||||
async fn append(&self, http: &reqwest::Client, key: &str, origin: &str) -> Result<()> {
|
||||
let res = http
|
||||
.post(format!("{}/agents/sessions/{}/events", self.base, self.session))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&json!({ "kind": "tool-call", "payload": self.payload(key, origin) }))
|
||||
.send()
|
||||
.await
|
||||
.context("append")?;
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("append: {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the console reads.
|
||||
///
|
||||
/// Bucket and key rather than a URL: the only URL for a private object is a
|
||||
/// signed, expiring one, and writing a credential into a transcript would be
|
||||
/// a leak that outlives the session. A reader resolves the key through the
|
||||
/// same door, under their own identity, whenever they open the event.
|
||||
fn payload(&self, key: &str, origin: &str) -> Value {
|
||||
json!({
|
||||
"tool": "shot",
|
||||
"route": self.route,
|
||||
"bucket": BUCKET,
|
||||
"key": key,
|
||||
"origin": origin,
|
||||
"viewport": { "width": VIEW.0, "height": VIEW.1 },
|
||||
"files": self.files,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A session id fit to be spliced into a URL path and an object key. An
|
||||
/// environment variable is caller-controlled input, and this one addresses
|
||||
/// another tenant's session if it is allowed to contain a slash or a dot-dot.
|
||||
fn handle(raw: &str) -> Option<&str> {
|
||||
let id = raw.trim();
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
|
||||
ok.then_some(id)
|
||||
}
|
||||
|
||||
/// Photograph a URL in a browser of this function's own.
|
||||
///
|
||||
/// Its own, not the agent's: the `browser` tool's page belongs to the
|
||||
/// conversation, and navigating it away to take this picture would move the
|
||||
/// ground under whatever the agent was looking at. Headless with a temporary
|
||||
/// profile, so it carries none of the operator's cookies to a page it was told
|
||||
/// about by a model.
|
||||
async fn shoot(url: &str) -> Result<Vec<u8>> {
|
||||
let browser = BrowserManager::new(BrowserConfig {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
persist_profile: false,
|
||||
viewport: ViewportConfig {
|
||||
width: VIEW.0,
|
||||
height: VIEW.1,
|
||||
device_scale_factor: 1.0,
|
||||
mobile: false,
|
||||
},
|
||||
..BrowserConfig::default()
|
||||
});
|
||||
let png = frame(&browser, url).await;
|
||||
let _ = browser.stop().await;
|
||||
png
|
||||
}
|
||||
|
||||
async fn frame(browser: &BrowserManager, url: &str) -> Result<Vec<u8>> {
|
||||
browser.start().await.context("launch")?;
|
||||
browser.goto(url).await.with_context(|| format!("open {url}"))?;
|
||||
let page = browser.get_or_create_page().await.context("page")?;
|
||||
let shots = page
|
||||
.screenshot(ScreenshotMode::Viewport)
|
||||
.await
|
||||
.context("capture")?;
|
||||
shots
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|s| s.data)
|
||||
.ok_or_else(|| anyhow!("empty capture"))
|
||||
}
|
||||
|
||||
/// Upload the bytes with the signature and nothing else. Our bearer is for the
|
||||
/// API; sending it to whatever host cloud signed against would hand an org
|
||||
/// credential to a third party for no reason.
|
||||
async fn put(http: &reqwest::Client, signed: &str, png: Vec<u8>) -> Result<()> {
|
||||
let res = http
|
||||
.put(signed)
|
||||
.header(reqwest::header::CONTENT_TYPE, "image/png")
|
||||
.body(png)
|
||||
.send()
|
||||
.await
|
||||
.context("upload")?;
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("upload: {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn plan() -> Plan {
|
||||
Plan {
|
||||
cwd: PathBuf::from("/home/someone/work/web"),
|
||||
session: "ses_abc123".to_string(),
|
||||
base: "https://api.hanzo.ai/v1".to_string(),
|
||||
token: "hk-secret-value".to_string(),
|
||||
route: "/settings".to_string(),
|
||||
files: vec!["src/app/settings/page.tsx".to_string()],
|
||||
slug: "settings".to_string(),
|
||||
n: 7,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_payload_names_the_object_and_what_earned_it() {
|
||||
let p = plan();
|
||||
let key = format!("{}/{}/{}-{}.png", BUCKET, p.session, p.n, p.slug);
|
||||
let body = p.payload(&key, "http://127.0.0.1:3000");
|
||||
assert_eq!(body["tool"], "shot");
|
||||
assert_eq!(body["route"], "/settings");
|
||||
assert_eq!(body["bucket"], "sessions");
|
||||
assert_eq!(body["key"], "sessions/ses_abc123/7-settings.png");
|
||||
assert_eq!(body["origin"], "http://127.0.0.1:3000");
|
||||
assert_eq!(body["viewport"]["width"], 1280);
|
||||
assert_eq!(body["viewport"]["height"], 800);
|
||||
assert_eq!(body["files"][0], "src/app/settings/page.tsx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_payload_carries_no_credential() {
|
||||
let p = plan();
|
||||
let body = p.payload("sessions/ses_abc123/7-settings.png", "http://127.0.0.1:3000").to_string();
|
||||
assert!(!body.contains(&p.token));
|
||||
assert!(!body.contains("hk-"));
|
||||
// A presigned URL is a bearer with an expiry. It must never be written down.
|
||||
assert!(!body.contains("X-Amz-Signature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_payload_names_no_absolute_path() {
|
||||
let body = plan()
|
||||
.payload("sessions/ses_abc123/7-settings.png", "http://127.0.0.1:3000")
|
||||
.to_string();
|
||||
assert!(!body.contains("/home/someone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_payload_fits_the_log() {
|
||||
// Cloud refuses a payload over 64 KiB. The cap on named files is what
|
||||
// keeps this true for a turn that rewrites a design system.
|
||||
let mut p = plan();
|
||||
p.files = (0..NAMED)
|
||||
.map(|i| format!("src/app/{}/page.tsx", "n".repeat(200 + i)))
|
||||
.collect();
|
||||
let body = p.payload("sessions/ses_abc123/7-settings.png", "http://127.0.0.1:3000");
|
||||
assert!(body.to_string().len() < 64 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_id_cannot_retarget_the_request() {
|
||||
for bad in [
|
||||
"",
|
||||
" ",
|
||||
"../../admin",
|
||||
"ses_abc/../../other",
|
||||
"ses abc",
|
||||
"ses_abc?x=1",
|
||||
"ses_abc#frag",
|
||||
"ses_abc/events",
|
||||
"https://evil.example/",
|
||||
] {
|
||||
assert_eq!(handle(bad), None, "{bad} must be refused");
|
||||
}
|
||||
assert_eq!(handle(" ses_abc123 "), Some("ses_abc123"));
|
||||
assert_eq!(handle("ses-ABC_123"), Some("ses-ABC_123"));
|
||||
assert_eq!(handle(&"a".repeat(128)), Some("a".repeat(128).as_str()));
|
||||
assert_eq!(handle(&"a".repeat(129)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
//! What a diff touched, whether any of it renders, and where it renders.
|
||||
//!
|
||||
//! Everything here is a decision over strings and paths — no clock, no network,
|
||||
//! no filesystem. That is the point of the split: the effectful half next door
|
||||
//! has nothing left to decide, and every rule below can be read straight out of
|
||||
//! a test instead of out of a running browser.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Extensions whose content IS the rendered surface.
|
||||
///
|
||||
/// Deliberately short. A picture costs a browser launch and, more expensively, a
|
||||
/// reviewer's attention — so the predicate only fires on extensions that cannot
|
||||
/// mean anything except markup or style. `.ts` and `.js` are absent on purpose:
|
||||
/// most of them are logic, and Next's `app/api/route.ts` is a server handler
|
||||
/// sitting in the middle of the UI tree. A change that genuinely moves pixels
|
||||
/// nearly always arrives with a partner carrying one of these.
|
||||
const RENDERED: [&str; 9] = [
|
||||
"astro", "css", "html", "jsx", "less", "scss", "svelte", "tsx", "vue",
|
||||
];
|
||||
|
||||
/// Directories whose contents were downloaded or generated. A `.css` under any
|
||||
/// of them is output, not authorship, and photographing it tells a reviewer
|
||||
/// nothing about what the agent did.
|
||||
const DERIVED: [&str; 8] = [
|
||||
".next",
|
||||
".nuxt",
|
||||
".svelte-kit",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
"target",
|
||||
];
|
||||
|
||||
/// Directory names that route by nesting: the URL is the directory path and only
|
||||
/// the router's own leaf file marks a directory as a route.
|
||||
const NESTED: [&str; 2] = ["app", "routes"];
|
||||
|
||||
/// The directory that routes by filename: `pages/about.tsx` is `/about`.
|
||||
const FLAT: &str = "pages";
|
||||
|
||||
/// Leaf filenames that name their directory rather than a segment of their own —
|
||||
/// Next's `page.tsx`, SvelteKit's `+page.svelte`.
|
||||
const LEAF: [&str; 2] = ["page", "+page"];
|
||||
|
||||
/// The paths a unified diff writes.
|
||||
///
|
||||
/// `+++ b/<path>` is the post-image name: the file as it exists *after* the
|
||||
/// patch, which is the only version there is anything to photograph. A deletion
|
||||
/// writes `+++ /dev/null` and names nothing, so it drops out here for free.
|
||||
///
|
||||
/// Paths arrive relative to the git root when there is one and absolute when
|
||||
/// there is not, so callers must relativize before publishing anything.
|
||||
pub(crate) fn touched(diff: &str) -> Vec<PathBuf> {
|
||||
diff.lines()
|
||||
.filter_map(|line| line.strip_prefix("+++ "))
|
||||
// Some unified-diff writers append a tab and a timestamp to the header.
|
||||
.map(|rest| rest.split('\t').next().unwrap_or(rest))
|
||||
.filter_map(|rest| rest.strip_prefix("b/"))
|
||||
.filter(|rest| !rest.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether this file, changed, is a reason to look at a page.
|
||||
pub(crate) fn rendered(path: &Path) -> bool {
|
||||
if path
|
||||
.components()
|
||||
.any(|c| DERIVED.contains(&c.as_os_str().to_string_lossy().as_ref()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some(ext) => RENDERED.contains(&ext.to_ascii_lowercase().as_str()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL path a source file renders at, for the file-routed frameworks where
|
||||
/// the answer is written in the path itself — Next (both routers), SvelteKit,
|
||||
/// Nuxt, Astro, Remix.
|
||||
///
|
||||
/// `None` means "not derivable", and the caller opens the site root. That is the
|
||||
/// deliberate answer for a dynamic segment like `[slug]`: a reviewer can tell a
|
||||
/// front page from the page they expected, but cannot tell an invented id from a
|
||||
/// broken route.
|
||||
pub(crate) fn route(path: &Path) -> Option<String> {
|
||||
let parts: Vec<String> = path
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let file = parts.last()?;
|
||||
let stem = Path::new(file).file_stem()?.to_str()?;
|
||||
|
||||
// The router directory this file hangs off. Innermost wins, so a `pages/`
|
||||
// nested inside an `app/` routes the file that sits under it.
|
||||
let root = parts
|
||||
.iter()
|
||||
.rposition(|p| NESTED.contains(&p.as_str()) || p == FLAT)?;
|
||||
let mut segs: Vec<&str> = parts[root + 1..parts.len() - 1]
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
|
||||
if parts[root] == FLAT {
|
||||
// `pages/` names a route by the file; `index` names the directory.
|
||||
if stem != "index" {
|
||||
segs.push(stem);
|
||||
}
|
||||
} else if !LEAF.contains(&stem) {
|
||||
// Under `app/` and `routes/` a directory is a route only because the
|
||||
// router's leaf file says so. Everything else living there — a stylesheet,
|
||||
// a component, a layout — has no URL of its own.
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url = String::new();
|
||||
for seg in segs {
|
||||
// A Next route group organises files without appearing in the URL.
|
||||
if seg.starts_with('(') && seg.ends_with(')') {
|
||||
continue;
|
||||
}
|
||||
if dynamic(seg) {
|
||||
return None;
|
||||
}
|
||||
url.push('/');
|
||||
url.push_str(seg);
|
||||
}
|
||||
Some(if url.is_empty() { "/".to_string() } else { url })
|
||||
}
|
||||
|
||||
/// A path segment standing in for a value the agent does not have: Next's
|
||||
/// `[slug]`, SvelteKit's `[id]`, Nuxt's `_id`, Remix's `$id`.
|
||||
fn dynamic(seg: &str) -> bool {
|
||||
seg.starts_with('[') || seg.starts_with('$') || seg.starts_with('_') || seg.starts_with(':')
|
||||
}
|
||||
|
||||
/// A short, key-safe name for the object, derived from the route when there is
|
||||
/// one and from the file otherwise. Lowercase alphanumerics and dashes only, so
|
||||
/// nothing here can shape the storage key it lands in.
|
||||
pub(crate) fn slug(route: &str, first: &Path) -> String {
|
||||
let raw = if route == "/" {
|
||||
first
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
route.to_string()
|
||||
};
|
||||
let mut out = String::new();
|
||||
for ch in raw.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.extend(ch.to_lowercase());
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
if out.len() >= 48 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let trimmed = out.trim_matches('-');
|
||||
if trimmed.is_empty() {
|
||||
"page".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The port the repository serves its UI on, read out of `package.json`: the
|
||||
/// repository's own dev script first, then the framework's default.
|
||||
///
|
||||
/// Reading, never running. The scripts in a `package.json` are arbitrary code
|
||||
/// belonging to the tree the agent is editing; this function parses one for a
|
||||
/// number and nothing else.
|
||||
pub(crate) fn port(manifest: &str) -> Option<u16> {
|
||||
let doc: serde_json::Value = serde_json::from_str(manifest).ok()?;
|
||||
let script = ["dev", "start", "serve"]
|
||||
.iter()
|
||||
.find_map(|name| doc["scripts"][name].as_str());
|
||||
if let Some(explicit) = script.and_then(declared) {
|
||||
return Some(explicit);
|
||||
}
|
||||
let deps = ["dependencies", "devDependencies"];
|
||||
let has = |name: &str| deps.iter().any(|d| !doc[d][name].is_null());
|
||||
// Ordered most specific first: SvelteKit and Nuxt both also carry the
|
||||
// bundler they are built on, and the framework's port is the one that wins.
|
||||
let by_framework = [
|
||||
("@sveltejs/kit", 5173u16),
|
||||
("nuxt", 3000),
|
||||
("astro", 4321),
|
||||
("@angular/cli", 4200),
|
||||
("gatsby", 8000),
|
||||
("next", 3000),
|
||||
("react-scripts", 3000),
|
||||
("@remix-run/dev", 3000),
|
||||
("vite", 5173),
|
||||
("@vitejs/plugin-react", 5173),
|
||||
];
|
||||
by_framework
|
||||
.iter()
|
||||
.find(|(name, _)| has(name))
|
||||
.map(|(_, port)| *port)
|
||||
.or(script.map(|_| 3000))
|
||||
}
|
||||
|
||||
/// A port named on a dev script's own command line: `--port 4000`, `--port=4000`
|
||||
/// or `-p 4000`.
|
||||
fn declared(script: &str) -> Option<u16> {
|
||||
let mut words = script.split_whitespace().peekable();
|
||||
while let Some(word) = words.next() {
|
||||
let value = match word.split_once('=') {
|
||||
Some(("--port", v)) => Some(v),
|
||||
_ if word == "--port" || word == "-p" => words.peek().copied(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(parsed) = value.and_then(|v| v.parse::<u16>().ok()) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn diff_for(paths: &[&str]) -> String {
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
format!("diff --git a/{p} b/{p}\nindex 000..111\n--- a/{p}\n+++ b/{p}\n@@ -1 +1 @@\n-a\n+b\n")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ui_of(paths: &[&str]) -> Vec<PathBuf> {
|
||||
touched(&diff_for(paths))
|
||||
.into_iter()
|
||||
.filter(|p| rendered(p))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ui_file_is_a_reason_to_look() {
|
||||
for path in [
|
||||
"src/app/page.tsx",
|
||||
"src/Button.jsx",
|
||||
"components/Card.vue",
|
||||
"src/routes/+page.svelte",
|
||||
"public/index.html",
|
||||
"styles/main.css",
|
||||
"styles/main.scss",
|
||||
"styles/main.less",
|
||||
"src/pages/about.astro",
|
||||
] {
|
||||
assert!(rendered(Path::new(path)), "{path} should count as UI");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_file_is_not() {
|
||||
for path in [
|
||||
"apps/agents/sessions.go",
|
||||
"core/src/shot/mod.rs",
|
||||
"scripts/deploy.py",
|
||||
"migrations/001.sql",
|
||||
"Cargo.toml",
|
||||
"LLM.md",
|
||||
"src/lib/format.ts",
|
||||
"src/app/api/route.ts",
|
||||
"Makefile",
|
||||
] {
|
||||
assert!(!rendered(Path::new(path)), "{path} should not count as UI");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_output_is_not_authorship() {
|
||||
for path in [
|
||||
"node_modules/react/index.css",
|
||||
"dist/app.css",
|
||||
".next/static/chunk.css",
|
||||
"build/index.html",
|
||||
"out/index.html",
|
||||
"target/doc/style.css",
|
||||
] {
|
||||
assert!(!rendered(Path::new(path)), "{path} is derived, not UI");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_only_diff_yields_nothing() {
|
||||
assert!(ui_of(&["apps/agents/sessions.go", "core/src/codex.rs"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_diff_yields_the_ui_half() {
|
||||
let ui = ui_of(&[
|
||||
"apps/agents/sessions.go",
|
||||
"web/src/app/settings/page.tsx",
|
||||
"core/src/codex.rs",
|
||||
]);
|
||||
assert_eq!(ui, vec![PathBuf::from("web/src/app/settings/page.tsx")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_file_names_nothing() {
|
||||
let diff = "diff --git a/src/Old.tsx b/src/Old.tsx\ndeleted file mode 100644\n--- a/src/Old.tsx\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone\n";
|
||||
assert!(touched(diff).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_names_the_destination() {
|
||||
let diff = "diff --git a/src/A.tsx b/src/B.tsx\nindex 000..111\n--- a/src/A.tsx\n+++ b/src/B.tsx\n@@ -1 +1 @@\n-a\n+b\n";
|
||||
assert_eq!(touched(diff), vec![PathBuf::from("src/B.tsx")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_timestamp_is_not_part_of_the_path() {
|
||||
let diff = "--- a/src/A.tsx\t2026-01-01 00:00:00\n+++ b/src/A.tsx\t2026-01-01 00:00:01\n";
|
||||
assert_eq!(touched(diff), vec![PathBuf::from("src/A.tsx")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absolute_path_survives_intact() {
|
||||
let diff = "--- a//home/z/web/src/A.tsx\n+++ b//home/z/web/src/A.tsx\n";
|
||||
assert_eq!(touched(diff), vec![PathBuf::from("/home/z/web/src/A.tsx")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_are_read_off_the_path() {
|
||||
let cases = [
|
||||
("src/app/page.tsx", Some("/")),
|
||||
("src/app/settings/page.tsx", Some("/settings")),
|
||||
("app/(marketing)/pricing/page.tsx", Some("/pricing")),
|
||||
("src/routes/blog/+page.svelte", Some("/blog")),
|
||||
("src/pages/about.tsx", Some("/about")),
|
||||
("src/pages/index.tsx", Some("/")),
|
||||
("src/pages/blog/index.vue", Some("/blog")),
|
||||
("src/pages/docs/intro.astro", Some("/docs/intro")),
|
||||
];
|
||||
for (path, want) in cases {
|
||||
assert_eq!(
|
||||
route(Path::new(path)).as_deref(),
|
||||
want,
|
||||
"route for {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_underivable_route_is_the_root() {
|
||||
for path in [
|
||||
// A value the agent does not have.
|
||||
"app/blog/[slug]/page.tsx",
|
||||
"src/routes/user/[id]/+page.svelte",
|
||||
"src/pages/posts/_id.vue",
|
||||
"app/$team/page.tsx",
|
||||
// Not a route at all: a component, a stylesheet, a layout.
|
||||
"src/components/Button.tsx",
|
||||
"src/app/globals.css",
|
||||
"src/app/layout.tsx",
|
||||
"src/routes/styles.css",
|
||||
] {
|
||||
assert_eq!(route(Path::new(path)), None, "route for {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_carry_no_punctuation() {
|
||||
assert_eq!(slug("/settings/team", Path::new("x.tsx")), "settings-team");
|
||||
assert_eq!(slug("/", Path::new("src/Button.tsx")), "button");
|
||||
assert_eq!(slug("/", Path::new("src/.hidden")), "hidden");
|
||||
assert_eq!(slug("/", Path::new("/")), "page");
|
||||
assert!(slug("/a/very/long/route/that/keeps/going/on/and/on/forever/and/ever", Path::new("x.tsx")).len() <= 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dev_script_names_its_own_port() {
|
||||
for (script, want) in [
|
||||
(r#"{"scripts":{"dev":"next dev --port 4000"}}"#, 4000u16),
|
||||
(r#"{"scripts":{"dev":"next dev --port=4100"}}"#, 4100),
|
||||
(r#"{"scripts":{"dev":"vite -p 4200"}}"#, 4200),
|
||||
] {
|
||||
assert_eq!(port(script), Some(want), "{script}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_framework_names_the_default() {
|
||||
for (manifest, want) in [
|
||||
(r#"{"scripts":{"dev":"next dev"},"dependencies":{"next":"15"}}"#, 3000u16),
|
||||
(r#"{"scripts":{"dev":"vite"},"devDependencies":{"vite":"5"}}"#, 5173),
|
||||
(r#"{"scripts":{"dev":"astro dev"},"dependencies":{"astro":"4"}}"#, 4321),
|
||||
(r#"{"scripts":{"dev":"ng serve"},"devDependencies":{"@angular/cli":"18"}}"#, 4200),
|
||||
(
|
||||
r#"{"scripts":{"dev":"vite dev"},"devDependencies":{"@sveltejs/kit":"2","vite":"5"}}"#,
|
||||
5173,
|
||||
),
|
||||
(r#"{"scripts":{"dev":"nuxt dev"},"dependencies":{"nuxt":"3","vite":"5"}}"#, 3000),
|
||||
] {
|
||||
assert_eq!(port(manifest), Some(want), "{manifest}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manifest_that_serves_nothing_names_no_port() {
|
||||
assert_eq!(port(r#"{"name":"lib","scripts":{"build":"tsc"}}"#), None);
|
||||
assert_eq!(port("not json"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user