Compare commits

..
Author SHA1 Message Date
Abdul WahabandCursor 6a11efcc59 Test: read SOCKS handshake frames with read_exact
The mock proxy now reassembles greeting and CONNECT across TCP fragments so the ALL_PROXY regression cannot flake on a short read.

AI assistance disclosure: this commit was prepared with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 14:35:18 +05:00
Abdul WahabandCursor ff65ce1bc6 Test: cover SOCKS5 ALL_PROXY on the shared HTTP agent
The socks-proxy feature existed so ALL_PROXY=socks5:// can connect; the new test actually dials that path.

AI assistance disclosure: this commit was prepared with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 14:27:30 +05:00
Abdul WahabandCursor cb2a9af35b Fix: honor proxy environment variables in native downloads (#823)
The shared ureq agent now reads HTTP_PROXY/HTTPS_PROXY/ALL_PROXY so update and install work behind a corporate proxy. SOCKS is compiled in because ureq prefers ALL_PROXY, which is often socks5.

AI assistance disclosure: this commit was prepared with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 14:18:04 +05:00
6 changed files with 276 additions and 24 deletions
Generated
+40
View File
@@ -71,6 +71,12 @@ version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "byteorder-lite" name = "byteorder-lite"
version = "0.1.0" version = "0.1.0"
@@ -1334,6 +1340,17 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -1526,6 +1543,7 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"socks",
"url", "url",
"webpki-roots 0.26.11", "webpki-roots 0.26.11",
] ]
@@ -1656,6 +1674,28 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
+1 -1
View File
@@ -20,7 +20,7 @@ regex = { workspace = true }
once_cell = { workspace = true } once_cell = { workspace = true }
sha2 = "0.10" sha2 = "0.10"
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] } flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
ureq = { version = "2", default-features = false, features = ["tls", "json"] } ureq = { version = "2", default-features = false, features = ["tls", "json", "socks-proxy"] }
rustls-native-certs = "0.8" rustls-native-certs = "0.8"
webpki-roots = "1" webpki-roots = "1"
tiny_http = "0.12" tiny_http = "0.12"
+1
View File
@@ -485,6 +485,7 @@ mod tests {
use std::time::Duration; use std::time::Duration;
fn round_trip(edit: bool, override_model: Option<&str>, background: Option<&str>) { fn round_trip(edit: bool, override_model: Option<&str>, background: Option<&str>) {
let _proxy_lock = crate::http::PROXY_ENV_LOCK.lock().unwrap();
let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let api_base = format!("http://{}", server.server_addr()); let api_base = format!("http://{}", server.server_addr());
let temp = std::env::temp_dir().join(format!("impeccable-image-{}-{}", std::process::id(), server.server_addr().to_ip().unwrap().port())); let temp = std::env::temp_dir().join(format!("impeccable-image-{}-{}", std::process::id(), server.server_addr().to_ip().unwrap().port()));
+233 -4
View File
@@ -15,18 +15,30 @@
//! fails to load, verifies against the bundled roots exactly as before. //! fails to load, verifies against the bundled roots exactly as before.
//! `SSL_CERT_FILE` / `SSL_CERT_DIR` stand in for the OS store, as they do //! `SSL_CERT_FILE` / `SSL_CERT_DIR` stand in for the OS store, as they do
//! for OpenSSL and curl; the bundled roots stay either way. //! for OpenSSL and curl; the bundled roots stay either way.
//!
//! The shared agent builder also honors `ALL_PROXY`, `HTTPS_PROXY`, and
//! `HTTP_PROXY` (and their lowercase forms) so `update` and `install` work
//! behind a corporate proxy (#823). Live-mode localhost HTTP does not use
//! this builder. The `socks-proxy` feature is enabled because ureq 2.x
//! prefers `ALL_PROXY`, which is often `socks5://`. We opt in on this
//! builder only, not globally via ureq's `proxy-from-env` feature.
use std::sync::Arc; use std::sync::Arc;
#[cfg(test)]
pub(crate) static PROXY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use ureq::rustls::pki_types::CertificateDer; use ureq::rustls::pki_types::CertificateDer;
use ureq::rustls::{self, ClientConfig, RootCertStore}; use ureq::rustls::{self, ClientConfig, RootCertStore};
/// `ureq::AgentBuilder::new()` with the engine's trust store installed. /// `ureq::AgentBuilder::new()` with the engine's trust store installed and
/// Every HTTPS call site builds its agent from this; the plain-HTTP calls /// env proxy vars honored. Every HTTPS call site builds its agent from this;
/// to the live server on localhost do not need it. /// the plain-HTTP calls to the live server on localhost do not use it.
pub fn agent_builder() -> ureq::AgentBuilder { pub fn agent_builder() -> ureq::AgentBuilder {
ureq::AgentBuilder::new().tls_config(tls_config()) ureq::AgentBuilder::new()
.tls_config(tls_config())
.try_proxy_from_env(true)
} }
fn tls_config() -> Arc<ClientConfig> { fn tls_config() -> Arc<ClientConfig> {
@@ -99,6 +111,223 @@ tB0WGTOG3QIgdJa8gBPU9Y6WsrursItsnUeGTYHKDCZZ6MjlekLFuoc=
fn agent_builds_from_this_hosts_store() { fn agent_builds_from_this_hosts_store() {
// Runs the real rustls-native-certs load: it must not panic, and the // Runs the real rustls-native-certs load: it must not panic, and the
// shared config must be accepted by a ureq agent. // shared config must be accepted by a ureq agent.
let _lock = PROXY_ENV_LOCK.lock().unwrap();
let _agent = agent_builder().build(); let _agent = agent_builder().build();
} }
struct ProxyEnvGuard {
saved: Vec<(String, Option<String>)>,
}
impl ProxyEnvGuard {
fn set(vars: &[(&str, Option<&str>)]) -> Self {
let saved = vars
.iter()
.map(|(key, _)| (key.to_string(), std::env::var(key).ok()))
.collect();
for (key, value) in vars {
match value {
// SAFETY: PROXY_ENV_LOCK serializes every test that
// reads or writes these process-global proxy vars.
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
Self { saved }
}
}
impl Drop for ProxyEnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
// SAFETY: same lock as set(); restore before unlock.
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
}
}
fn accept_until(
listener: std::net::TcpListener,
mut handle: impl FnMut(&mut std::net::TcpStream) -> bool,
) {
use std::time::Duration;
listener
.set_nonblocking(true)
.expect("nonblocking proxy listener");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
let Ok((mut stream, _)) = listener.accept() else {
std::thread::sleep(Duration::from_millis(10));
continue;
};
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(2)));
if handle(&mut stream) {
return;
}
}
}
#[test]
fn agent_honors_http_proxy_from_env() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
let _lock = PROXY_ENV_LOCK.lock().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").expect("bind proxy listener");
let proxy_addr = listener.local_addr().expect("proxy listener addr");
let request = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let request_for_thread = request.clone();
let handle = std::thread::spawn(move || {
accept_until(listener, |stream| {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let chunk = &buf[..n];
if chunk.is_empty()
|| !String::from_utf8_lossy(chunk).contains("proxy-test.invalid")
{
return false;
}
request_for_thread.lock().unwrap().extend_from_slice(chunk);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
true
});
});
let proxy_url = format!("http://127.0.0.1:{}", proxy_addr.port());
let _env_guard = ProxyEnvGuard::set(&[
("ALL_PROXY", None),
("all_proxy", None),
("HTTPS_PROXY", None),
("https_proxy", None),
("HTTP_PROXY", Some(&proxy_url)),
("http_proxy", Some(&proxy_url)),
]);
let agent = agent_builder()
.timeout(Duration::from_secs(2))
.build();
let response = agent.get("http://proxy-test.invalid/").call();
assert!(response.is_ok(), "expected proxy-routed GET to succeed");
handle.join().expect("proxy thread");
let request_bytes = request.lock().unwrap().clone();
let request_text = String::from_utf8_lossy(&request_bytes);
assert!(
request_text.contains("proxy-test.invalid"),
"proxy should receive request for target host, got: {request_text:?}"
);
}
#[test]
fn agent_honors_socks5_all_proxy_from_env() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
fn socks5_then_http(stream: &mut std::net::TcpStream) -> Option<Vec<u8>> {
fn read_n(stream: &mut std::net::TcpStream, n: usize) -> Option<Vec<u8>> {
let mut buf = vec![0u8; n];
stream.read_exact(&mut buf).ok()?;
Some(buf)
}
let greet = read_n(stream, 2)?;
if greet[0] != 5 {
return None;
}
let _ = read_n(stream, greet[1] as usize)?;
stream.write_all(&[0x05, 0x00]).ok()?;
let req = read_n(stream, 4)?;
if req[0] != 5 || req[1] != 1 {
return None;
}
match req[3] {
1 => {
let _ = read_n(stream, 6)?;
}
3 => {
let len = read_n(stream, 1)?;
let _ = read_n(stream, len[0] as usize + 2)?;
}
4 => {
let _ = read_n(stream, 18)?;
}
_ => return None,
}
stream
.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.ok()?;
let mut chunk = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = stream.read(&mut buf).ok()?;
if n == 0 {
break;
}
chunk.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&chunk).contains("proxy-test.invalid") {
break;
}
}
if !String::from_utf8_lossy(&chunk).contains("proxy-test.invalid") {
return None;
}
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
Some(chunk)
}
let _lock = PROXY_ENV_LOCK.lock().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").expect("bind socks listener");
let proxy_addr = listener.local_addr().expect("socks listener addr");
let request = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let request_for_thread = request.clone();
let handle = std::thread::spawn(move || {
accept_until(listener, |stream| {
if let Some(chunk) = socks5_then_http(stream) {
*request_for_thread.lock().unwrap() = chunk;
true
} else {
false
}
});
});
let proxy_url = format!("socks5://127.0.0.1:{}", proxy_addr.port());
let _env_guard = ProxyEnvGuard::set(&[
("ALL_PROXY", Some(&proxy_url)),
("all_proxy", Some(&proxy_url)),
("HTTPS_PROXY", None),
("https_proxy", None),
("HTTP_PROXY", None),
("http_proxy", None),
]);
let agent = agent_builder()
.timeout(Duration::from_secs(2))
.build();
let response = agent.get("http://proxy-test.invalid/").call();
assert!(response.is_ok(), "expected SOCKS5-routed GET to succeed");
handle.join().expect("socks thread");
let request_bytes = request.lock().unwrap().clone();
let request_text = String::from_utf8_lossy(&request_bytes);
assert!(
request_text.contains("proxy-test.invalid"),
"SOCKS proxy should receive request for target host, got: {request_text:?}"
);
}
} }
+1 -1
View File
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `{{scripts_path}}/impeccable concept-seed --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. <!-- rule:skill-concept-procedure --> <!-- rule:skill-verdict-and-donation --> 4. Run `{{scripts_path}}/impeccable concept-seed --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. <!-- rule:skill-concept-procedure --> <!-- rule:skill-verdict-and-donation -->
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. <!-- rule:skill-pick-card-one-only --> 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. <!-- rule:skill-pick-card-one-only -->
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. <!-- rule:skill-canon-standing-exit --> Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. <!-- rule:skill-assigned-plus-reroll --> Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `{{scripts_path}}/impeccable serve-question --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. A harness that cannot hold a blocking `--wait` (it backgrounds long-running commands, or reaps a backgrounded child) takes the structured tool without starting the page and discloses the substitution in the first reply, so the tab is never left unanswered. Never predict any other fallback: run the script, and only exit code 2 from starting it, or that wait check before starting, routes the decision to the structured tool; those are the fallback, never an error to retry. <!-- rule:skill-visual-decision-page --> The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. <!-- rule:skill-canon-standing-exit --> Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. <!-- rule:skill-assigned-plus-reroll --> Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `{{scripts_path}}/impeccable serve-question --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. <!-- rule:skill-visual-decision-page -->
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. <!-- rule:skill-decision-comps-full-fidelity --> <!-- rule:skill-salience-parity --> When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. <!-- rule:skill-decision-comps-full-fidelity --> <!-- rule:skill-salience-parity -->
-18
View File
@@ -68,24 +68,6 @@ describe('skill reference authoring contracts', () => {
assert.doesNotMatch(polish, /git status|git log/); assert.doesNotMatch(polish, /git status|git log/);
}); });
it('routes visual decision fallback through wait capability and start failure', () => {
const newWork = readFileSync(join(ROOT, 'skill/reference/new-work.md'), 'utf-8').replace(/\r\n?/g, '\n');
const visualDecisionPage = newWork.match(
/A harness that can leave a shell blocked[\s\S]*?<!-- rule:skill-visual-decision-page -->/,
)?.[0] ?? '';
assert.match(visualDecisionPage, /cannot hold a blocking `--wait`/);
assert.match(visualDecisionPage, /without starting the page/);
assert.match(visualDecisionPage, /structured tool/);
assert.match(visualDecisionPage, /first reply/);
assert.match(visualDecisionPage, /exit code 2 from starting it/);
assert.match(visualDecisionPage, /wait check before starting/);
assert.doesNotMatch(
visualDecisionPage,
/only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback/,
);
});
it('keeps touch-gesture verification in the adapt, audit, and harden references', () => { it('keeps touch-gesture verification in the adapt, audit, and harden references', () => {
const adapt = readFileSync(join(ROOT, 'skill/reference/adapt.md'), 'utf-8').replace(/\r\n?/g, '\n'); const adapt = readFileSync(join(ROOT, 'skill/reference/adapt.md'), 'utf-8').replace(/\r\n?/g, '\n');
const audit = readFileSync(join(ROOT, 'skill/reference/audit.md'), 'utf-8').replace(/\r\n?/g, '\n'); const audit = readFileSync(join(ROOT, 'skill/reference/audit.md'), 'utf-8').replace(/\r\n?/g, '\n');