mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 16:46:31 +03:00
The engine verified TLS against the Mozilla roots bundled through webpki-roots only, so behind a TLS-inspecting proxy (Aikido, Zscaler, Netskope) whose root lives in the OS trust store, `impeccable update` and `install` failed with `invalid peer certificate: UnknownIssuer` while curl and npm on the same machine succeeded. crates/context/src/http.rs builds one rustls ClientConfig per process: the OS trust store (rustls-native-certs: Keychain, Windows store, the OpenSSL paths on Linux) merged with the bundled roots. A union, not a replacement, so a container without ca-certificates or a store that fails to load still verifies exactly as before. SSL_CERT_FILE and SSL_CERT_DIR replace the OS store the way they do for OpenSSL and curl. Every HTTPS call site (bundle and signature downloads, /api/version, /api/commands, the roll API, image generation) builds its agent from this module; the plain-HTTP live-server calls on localhost are untouched. Verified against a local HTTPS server signed by a throwaway CA: trusted through SSL_CERT_FILE the update check reaches it; without it the same server is rejected as UnknownIssuer; with SSL_CERT_FILE pointing at that CA or at a missing file, impeccable.style still verifies through the bundled roots. cargo test --workspace and the oracle corpus (832) pass. Written with AI assistance (Claude Code). Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
433 lines
16 KiB
Rust
433 lines
16 KiB
Rust
//! JS: generate-image.mjs -> `impeccable generate-image`
|
|
|
|
use crate::jsp;
|
|
use crate::util::{iso_now, json_pretty, node_read_error, utf16_len, Env};
|
|
use impeccable_common::Io;
|
|
use serde_json::{Map, Value};
|
|
use std::io::Write;
|
|
|
|
fn arg(args: &[String], name: &str) -> Option<String> {
|
|
let i = args.iter().position(|a| a == &format!("--{}", name))?;
|
|
let v = args.get(i + 1)?;
|
|
if !v.is_empty() && !v.starts_with("--") {
|
|
Some(v.clone())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn hash32(s: &str) -> u32 {
|
|
let mut h: u32 = 0x811c9dc5;
|
|
for cu in s.encode_utf16() {
|
|
h ^= cu as u32;
|
|
h = h.wrapping_mul(0x01000193);
|
|
}
|
|
h
|
|
}
|
|
|
|
fn hsl_to_rgb(h_deg: f64, s: f64, l: f64) -> [u8; 3] {
|
|
let h = ((h_deg % 360.0) + 360.0) % 360.0 / 360.0;
|
|
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
|
let p = 2.0 * l - q;
|
|
let hue = |t: f64| -> f64 {
|
|
let mut tt = t;
|
|
if tt < 0.0 {
|
|
tt += 1.0;
|
|
}
|
|
if tt > 1.0 {
|
|
tt -= 1.0;
|
|
}
|
|
if tt < 1.0 / 6.0 {
|
|
return p + (q - p) * 6.0 * tt;
|
|
}
|
|
if tt < 1.0 / 2.0 {
|
|
return q;
|
|
}
|
|
if tt < 2.0 / 3.0 {
|
|
return p + (q - p) * (2.0 / 3.0 - tt) * 6.0;
|
|
}
|
|
p
|
|
};
|
|
let round = |c: f64| -> u8 { js_round(c * 255.0) as u8 };
|
|
[round(hue(h + 1.0 / 3.0)), round(hue(h)), round(hue(h - 1.0 / 3.0))]
|
|
}
|
|
|
|
/// Math.round: half up toward +inf
|
|
fn js_round(x: f64) -> f64 {
|
|
(x + 0.5).floor()
|
|
}
|
|
|
|
fn to_hex(c: [u8; 3]) -> String {
|
|
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
|
|
}
|
|
|
|
fn palette(prompt: &str) -> Vec<[u8; 3]> {
|
|
let h = hash32(prompt);
|
|
let base = (h % 360) as f64;
|
|
let bands = 2 + ((h >> 9) % 2) as usize;
|
|
let spread = (40 + (h >> 3) % 120) as f64;
|
|
let mut out = Vec::new();
|
|
for i in 0..bands {
|
|
let hue = base + i as f64 * spread;
|
|
let light = 0.32 + ((h >> (i * 5)) % 40) as f64 / 100.0;
|
|
out.push(hsl_to_rgb(hue, 0.55, light));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn collapse_ws(s: &str) -> String {
|
|
// .replace(/\s+/g, ' ').trim()
|
|
let mut out = String::new();
|
|
let mut in_ws = false;
|
|
for c in s.chars() {
|
|
if c.is_whitespace() || c == '\u{FEFF}' {
|
|
if !in_ws {
|
|
out.push(' ');
|
|
in_ws = true;
|
|
}
|
|
} else {
|
|
in_ws = false;
|
|
out.push(c);
|
|
}
|
|
}
|
|
crate::util::js_trim(&out).to_string()
|
|
}
|
|
|
|
fn num(v: f64) -> String {
|
|
crate::util::js_number_to_string(v)
|
|
}
|
|
|
|
fn svg_fake(prompt: &str, w: f64, h: f64) -> String {
|
|
let colors: Vec<String> = palette(prompt).into_iter().map(to_hex).collect();
|
|
let n = colors.len();
|
|
let stops: String = colors
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, c)| format!("<stop offset=\"{}%\" stop-color=\"{}\"/>", num(js_round(i as f64 / (n as f64 - 1.0) * 100.0)), c))
|
|
.collect();
|
|
let per_line = (12.0f64).max((w / 26.0).floor()) as usize;
|
|
let words: Vec<&str> = {
|
|
let collapsed = collapse_ws(prompt);
|
|
// split(' ') on the collapsed string; leak for lifetime simplicity
|
|
Box::leak(collapsed.into_boxed_str()).split(' ').collect()
|
|
};
|
|
let mut lines: Vec<String> = Vec::new();
|
|
let mut cur = String::new();
|
|
for word in words {
|
|
let candidate = crate::util::js_trim(&format!("{} {}", cur, word)).to_string();
|
|
if utf16_len(&candidate) > per_line {
|
|
if !cur.is_empty() {
|
|
lines.push(cur.clone());
|
|
}
|
|
cur = word.to_string();
|
|
} else {
|
|
cur = candidate;
|
|
}
|
|
if lines.len() >= 10 {
|
|
break;
|
|
}
|
|
}
|
|
if !cur.is_empty() && lines.len() < 11 {
|
|
lines.push(cur);
|
|
}
|
|
let escape = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">");
|
|
let font_size = js_round(w / 24.0);
|
|
let start_y = h / 2.0 - ((lines.len() as f64 - 1.0) * font_size * 1.3) / 2.0;
|
|
let text: String = lines
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, line)| {
|
|
format!(
|
|
"<text x=\"{}\" y=\"{}\" font-family=\"Helvetica, Arial, sans-serif\" font-size=\"{}\" fill=\"#ffffff\" text-anchor=\"middle\" dominant-baseline=\"middle\">{}</text>",
|
|
num(w / 2.0),
|
|
num(js_round(start_y + i as f64 * font_size * 1.3)),
|
|
num(font_size),
|
|
escape(line)
|
|
)
|
|
})
|
|
.collect();
|
|
format!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">\n <defs><linearGradient id=\"g\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">{stops}</linearGradient></defs>\n <rect width=\"{w}\" height=\"{h}\" fill=\"url(#g)\"/>\n <rect x=\"0\" y=\"0\" width=\"{w}\" height=\"{h}\" fill=\"#000000\" fill-opacity=\"0.22\"/>\n {text}\n <rect x=\"{x1}\" y=\"{y1}\" width=\"{bw}\" height=\"{bh}\" fill=\"#000000\" fill-opacity=\"0.55\"/>\n <text x=\"{tx}\" y=\"{ty}\" font-family=\"Helvetica, Arial, sans-serif\" font-size=\"{fs}\" letter-spacing=\"2\" fill=\"#ffffff\" text-anchor=\"middle\" dominant-baseline=\"middle\">SYNTHETIC COMP</text>\n</svg>\n",
|
|
w = num(w),
|
|
h = num(h),
|
|
stops = stops,
|
|
text = text,
|
|
x1 = num(w - js_round(w / 4.2)),
|
|
y1 = num(h - js_round(h / 16.0)),
|
|
bw = num(js_round(w / 4.2)),
|
|
bh = num(js_round(h / 16.0)),
|
|
tx = num(w - js_round(w / 8.4)),
|
|
ty = num(h - js_round(h / 32.0)),
|
|
fs = num(js_round(w / 60.0)),
|
|
)
|
|
}
|
|
|
|
fn crc32(data: &[u8]) -> u32 {
|
|
let mut c = 0xffffffffu32;
|
|
for b in data {
|
|
c ^= *b as u32;
|
|
for _ in 0..8 {
|
|
c = if c & 1 == 1 { 0xedb88320 ^ (c >> 1) } else { c >> 1 };
|
|
}
|
|
}
|
|
c ^ 0xffffffff
|
|
}
|
|
|
|
fn png_chunk(ty: &[u8], data: &[u8]) -> Vec<u8> {
|
|
let mut body = ty.to_vec();
|
|
body.extend_from_slice(data);
|
|
let mut out = Vec::new();
|
|
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
|
out.extend_from_slice(&body);
|
|
out.extend_from_slice(&crc32(&body).to_be_bytes());
|
|
out
|
|
}
|
|
|
|
fn png_fake(prompt: &str, w: usize, h: usize) -> Vec<u8> {
|
|
let colors = palette(prompt);
|
|
let band_h = (h as f64 / colors.len() as f64).ceil() as usize;
|
|
let stride = w * 3;
|
|
let mut raw = vec![0u8; h * (stride + 1)];
|
|
for y in 0..h {
|
|
let row = y * (stride + 1);
|
|
raw[row] = 0;
|
|
let idx = (colors.len() - 1).min(if band_h == 0 { 0 } else { y / band_h });
|
|
let [r, g, b] = colors[idx];
|
|
for x in 0..w {
|
|
let p = row + 1 + x * 3;
|
|
raw[p] = r;
|
|
raw[p + 1] = g;
|
|
raw[p + 2] = b;
|
|
}
|
|
}
|
|
let mut ihdr = vec![0u8; 13];
|
|
ihdr[..4].copy_from_slice(&(w as u32).to_be_bytes());
|
|
ihdr[4..8].copy_from_slice(&(h as u32).to_be_bytes());
|
|
ihdr[8] = 8;
|
|
ihdr[9] = 2;
|
|
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(9));
|
|
let _ = enc.write_all(&raw);
|
|
let idat = enc.finish().unwrap_or_default();
|
|
let mut text = b"Comment".to_vec();
|
|
text.push(0);
|
|
// latin1: chars > 0xff become their low byte in Node's 'latin1' encoding
|
|
for c in format!("SYNTHETIC COMP: {}", collapse_ws(prompt)).encode_utf16() {
|
|
text.push((c & 0xff) as u8);
|
|
}
|
|
let mut out = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
out.extend(png_chunk(b"IHDR", &ihdr));
|
|
out.extend(png_chunk(b"tEXt", &text));
|
|
out.extend(png_chunk(b"IDAT", &idat));
|
|
out.extend(png_chunk(b"IEND", &[]));
|
|
out
|
|
}
|
|
|
|
fn parse_size(s: &str) -> (usize, usize) {
|
|
if let Some((a, b)) = s.split_once('x') {
|
|
if !a.is_empty() && !b.is_empty() && a.chars().all(|c| c.is_ascii_digit()) && b.chars().all(|c| c.is_ascii_digit()) {
|
|
return (a.parse().unwrap_or(1536), b.parse().unwrap_or(1024));
|
|
}
|
|
}
|
|
(1536, 1024)
|
|
}
|
|
|
|
pub fn run(args: &[String], io: &mut Io) -> i32 {
|
|
let cwd = io.cwd.to_string_lossy().into_owned();
|
|
let env: Env = io.env.clone();
|
|
let abs = |p: &str| jsp::resolve(&cwd, &[p]);
|
|
let read_prompt_file = |io: &mut Io, pf: &str| -> Result<String, i32> {
|
|
match std::fs::read(abs(pf)) {
|
|
Ok(b) => Ok(String::from_utf8_lossy(&b).into_owned()),
|
|
Err(e) => {
|
|
io.err(&format!("Error: {}\n", node_read_error(pf, &e)));
|
|
Err(1)
|
|
}
|
|
}
|
|
};
|
|
if env.get("IMPECCABLE_IMAGE_GEN_FAKE").map(|v| !v.is_empty()).unwrap_or(false) {
|
|
let prompt = match arg(args, "prompt-file") {
|
|
Some(pf) => match read_prompt_file(io, &pf) {
|
|
Ok(p) => Some(p),
|
|
Err(c) => return c,
|
|
},
|
|
None => arg(args, "prompt"),
|
|
};
|
|
let out = arg(args, "out");
|
|
let (Some(prompt), Some(out)) = (prompt.filter(|p| !p.is_empty()), out) else {
|
|
io.err("generate-image: --prompt (or --prompt-file) and --out are required.\n");
|
|
return 1;
|
|
};
|
|
let (w, h) = parse_size(&arg(args, "size").unwrap_or_else(|| "1536x1024".into()));
|
|
let bytes = if out.ends_with(".svg") { svg_fake(&prompt, w as f64, h as f64).into_bytes() } else { png_fake(&prompt, w, h) };
|
|
if let Err(e) = std::fs::write(abs(&out), bytes) {
|
|
io.err(&format!("Error: {}\n", node_read_error(&out, &e)));
|
|
return 1;
|
|
}
|
|
io.out(&format!("IMAGE: {} ({}x{}, fake synthetic comp, $0.00, no API call)\n", out, w, h));
|
|
return 0;
|
|
}
|
|
let Some(key) = env.get("OPENAI_API_KEY").filter(|k| !k.is_empty()).cloned() else {
|
|
io.err("generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.\n");
|
|
return 1;
|
|
};
|
|
let prompt = match arg(args, "prompt-file") {
|
|
Some(pf) => match read_prompt_file(io, &pf) {
|
|
Ok(p) => Some(p),
|
|
Err(c) => return c,
|
|
},
|
|
None => arg(args, "prompt"),
|
|
};
|
|
let out = arg(args, "out");
|
|
let (Some(prompt), Some(out)) = (prompt.filter(|p| !p.is_empty()), out) else {
|
|
io.err("generate-image: --prompt (or --prompt-file) and --out are required.\n");
|
|
return 1;
|
|
};
|
|
let size = arg(args, "size").unwrap_or_else(|| "1536x1024".into());
|
|
let quality = arg(args, "quality").unwrap_or_else(|| "medium".into());
|
|
let mut refs: Vec<String> = Vec::new();
|
|
for i in 0..args.len() {
|
|
if args[i] == "--ref" {
|
|
if let Some(n) = args.get(i + 1) {
|
|
if !n.is_empty() && !n.starts_with("--") {
|
|
refs.push(n.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let agent = crate::http::agent_builder().build();
|
|
let response = if !refs.is_empty() {
|
|
let boundary = format!("----impeccable{:x}", crate::util::now_ms() as u64);
|
|
let mut body: Vec<u8> = Vec::new();
|
|
let mut field = |name: &str, value: &str| {
|
|
body.extend_from_slice(format!("--{}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", boundary, name, value).as_bytes());
|
|
};
|
|
field("model", "gpt-image-2");
|
|
field("prompt", &prompt);
|
|
field("size", &size);
|
|
field("quality", &quality);
|
|
field("n", "1");
|
|
for r in &refs {
|
|
let bytes = match std::fs::read(abs(r)) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
io.err(&format!("Error: {}\n", node_read_error(r, &e)));
|
|
return 1;
|
|
}
|
|
};
|
|
let ty = if r.ends_with(".png") {
|
|
"image/png"
|
|
} else if r.ends_with(".webp") {
|
|
"image/webp"
|
|
} else {
|
|
"image/jpeg"
|
|
};
|
|
let filename = r.rsplit('/').next().unwrap_or(r);
|
|
body.extend_from_slice(
|
|
format!("--{}\r\nContent-Disposition: form-data; name=\"image[]\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", boundary, filename, ty).as_bytes(),
|
|
);
|
|
body.extend_from_slice(&bytes);
|
|
body.extend_from_slice(b"\r\n");
|
|
}
|
|
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
|
|
agent
|
|
.post("https://api.openai.com/v1/images/edits")
|
|
.set("Authorization", &format!("Bearer {}", key))
|
|
.set("Content-Type", &format!("multipart/form-data; boundary={}", boundary))
|
|
.send_bytes(&body)
|
|
} else {
|
|
let mut m = Map::new();
|
|
m.insert("model".into(), Value::String("gpt-image-2".into()));
|
|
m.insert("prompt".into(), Value::String(prompt.clone()));
|
|
m.insert("size".into(), Value::String(size.clone()));
|
|
m.insert("quality".into(), Value::String(quality.clone()));
|
|
m.insert("n".into(), Value::from(1));
|
|
agent
|
|
.post("https://api.openai.com/v1/images/generations")
|
|
.set("Authorization", &format!("Bearer {}", key))
|
|
.set("content-type", "application/json")
|
|
.send_string(&serde_json::to_string(&Value::Object(m)).unwrap())
|
|
};
|
|
let (status, text) = match response {
|
|
Ok(r) => {
|
|
let st = r.status();
|
|
(st, r.into_string().unwrap_or_default())
|
|
}
|
|
Err(ureq::Error::Status(code, r)) => (code, r.into_string().unwrap_or_default()),
|
|
Err(e) => {
|
|
io.err(&format!("TypeError: fetch failed: {}\n", e));
|
|
return 1;
|
|
}
|
|
};
|
|
if !(200..300).contains(&status) {
|
|
let snippet: String = text.chars().take(300).collect();
|
|
io.err(&format!("generate-image: API error {}: {}\n", status, snippet));
|
|
return 1;
|
|
}
|
|
let json: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
|
let b64 = json.get("data").and_then(|d| d.get(0)).and_then(|d| d.get("b64_json")).and_then(|b| b.as_str()).filter(|s| !s.is_empty());
|
|
let Some(b64) = b64 else {
|
|
io.err("generate-image: no image in response\n");
|
|
return 1;
|
|
};
|
|
let bytes = base64_decode(b64);
|
|
let _ = std::fs::write(abs(&out), bytes);
|
|
// best-effort embed + sidecar
|
|
// JS-PARITY: generate-image.mjs#676 reports whether the embed actually
|
|
// succeeded. The install-path-with-spaces half of #676 is a JS-only
|
|
// subprocess concern (fileURLToPath vs URL.pathname); the engine embeds
|
|
// in-process, so only the success tracking and message carry over here.
|
|
let embedded;
|
|
{
|
|
let mut sub_io = Io::captured("", io.cwd.clone(), io.env.clone()).0;
|
|
let ret = crate::embed_prompt::run(&[out.clone(), "--prompt".to_string(), prompt.clone()], &mut sub_io);
|
|
embedded = ret == 0;
|
|
if !embedded {
|
|
io.err("generate-image: failed to embed prompt in the image\n");
|
|
}
|
|
let mut m = Map::new();
|
|
m.insert("prompt".into(), Value::String(prompt.clone()));
|
|
m.insert("createdAt".into(), Value::String(iso_now()));
|
|
m.insert("tool".into(), Value::String("impeccable generate-image".into()));
|
|
m.insert("model".into(), Value::String("gpt-image-2".into()));
|
|
if !refs.is_empty() {
|
|
m.insert("refs".into(), Value::Array(refs.iter().cloned().map(Value::String).collect()));
|
|
}
|
|
let _ = std::fs::write(abs(&format!("{}.json", out)), json_pretty(&Value::Object(m)));
|
|
}
|
|
io.out(&format!(
|
|
"IMAGE: {} ({}, {}, gpt-image-2, billed to your OpenAI key); {} at {}.json\n",
|
|
out,
|
|
size,
|
|
quality,
|
|
if embedded { "prompt embedded + sidecar" } else { "sidecar" },
|
|
out
|
|
));
|
|
0
|
|
}
|
|
|
|
fn base64_decode(s: &str) -> Vec<u8> {
|
|
let table = |c: u8| -> Option<u32> {
|
|
match c {
|
|
b'A'..=b'Z' => Some((c - b'A') as u32),
|
|
b'a'..=b'z' => Some((c - b'a' + 26) as u32),
|
|
b'0'..=b'9' => Some((c - b'0' + 52) as u32),
|
|
b'+' | b'-' => Some(62),
|
|
b'/' | b'_' => Some(63),
|
|
_ => None,
|
|
}
|
|
};
|
|
let mut out = Vec::new();
|
|
let mut acc: u32 = 0;
|
|
let mut bits = 0;
|
|
for b in s.bytes() {
|
|
let Some(v) = table(b) else { continue };
|
|
acc = (acc << 6) | v;
|
|
bits += 6;
|
|
if bits >= 8 {
|
|
bits -= 8;
|
|
out.push(((acc >> bits) & 0xff) as u8);
|
|
}
|
|
}
|
|
out
|
|
}
|