From 269ef357f75a0cbcc71973db1b891d6ff87fac67 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 24 Jul 2026 09:10:09 -0700 Subject: [PATCH 1/4] fix(desktop): parse runtime team instructions section (#2645) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .../ui/agentSessionTranscriptHelpers.test.mjs | 111 ++++++++++++++++++ .../ui/agentSessionTranscriptHelpers.ts | 64 +++++++--- .../e2e/observer-feed-screenshots.spec.ts | 4 +- 3 files changed, 158 insertions(+), 21 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index f9bc089d6..f0f4cbf36 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -681,3 +681,114 @@ test("parseSystemPromptSections splits on the LAST occurrence of the canonical d }, ]); }); + +// ── Modern [Team Instructions] bracket-header extraction ───────────────────── + +test("parseSystemPromptSections (modern) extracts bracket Team Instructions after Base+System", () => { + // with_team() emits "\n\n[Team Instructions]\n{instructions}" as a top-level + // bracket section — the same framing used by with_core() and with_canvas(). + const framed = [ + "[Base]", + "You are a helpful assistant.", + "", + "[System]", + "You are Agent X.", + "", + "[Team Instructions]", + "Always respond in markdown.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "You are a helpful assistant." }, + { title: "System", body: "You are Agent X." }, + { title: "Team Instructions", body: "Always respond in markdown." }, + ]); +}); + +test("parseSystemPromptSections (modern) extracts bracket Team Instructions after System-only", () => { + // [Base] absent; [Team Instructions] is a direct top-level bracket section. + const framed = [ + "[System]", + "You are Agent Y.", + "", + "[Team Instructions]", + "Keep responses concise.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "System", body: "You are Agent Y." }, + { title: "Team Instructions", body: "Keep responses concise." }, + ]); +}); + +test("parseSystemPromptSections (modern) handles bracket Team Instructions as start-of-string", () => { + // with_team() also handles prompt=None → "[Team Instructions]\n{instructions}". + const framed = ["[Team Instructions]", "Instructions only."].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Team Instructions", body: "Instructions only." }, + ]); +}); + +test("parseSystemPromptSections (modern) pins full 5-section shape: Base+System+Team+Core+Canvas", () => { + // Production shape from with_team() + with_core() + with_canvas(): all five sections present. + const framed = [ + "[Base]", + "You are a helpful AI assistant running in Buzz.", + "", + "[System]", + "You are Observer Agent. You coordinate multi-agent workflows.", + "", + "[Team Instructions]", + "Always tag on handoff.", + "Never expand scope without approval.", + "", + "[Agent Memory — core]", + "I am Observer Agent.", + "## Lessons Learned", + "Always tag on handoff.", + "", + "[Channel Canvas]", + "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "Last modified: 2026-07-11T10:00:00Z", + "Fetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "You are a helpful AI assistant running in Buzz." }, + { + title: "System", + body: "You are Observer Agent. You coordinate multi-agent workflows.", + }, + { + title: "Team Instructions", + body: "Always tag on handoff.\nNever expand scope without approval.", + }, + { + title: "Core Memory", + body: "I am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.", + }, + { + title: "Channel Canvas", + body: "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", + }, + ]); +}); + +test("parseSystemPromptSections (modern) does NOT split on bracket [Team Instructions] preceded by only a single newline", () => { + // The inline marker is "\n\n[Team Instructions]\n" — a single preceding newline + // must be kept literal inside System, same guard as canvas/core. + const framed = [ + "[System]", + "Persona preamble.", + "[Team Instructions]", + "This is persona text, not a real team block.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { + title: "System", + body: "Persona preamble.\n[Team Instructions]\nThis is persona text, not a real team block.", + }, + ]); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 8e1830dfc..09a2bb31c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -61,15 +61,12 @@ export function parsePromptText(text: string): { * deterministically. * * The harness composes the value in order: - * `[Base]\n{base}\n\n[System]\n{persona}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}` - * with any section omitted when absent. For team-pack agents the persona body - * already contains the pack-level instructions appended by `compose_prompt()` - * in `buzz-persona/src/resolve.rs`: - * `{persona_body}\n\n---\n# Team Instructions\n{pack_instructions}` - * Extraction runs in reverse producer order so that each `lastIndexOf` search - * operates on the full input and each extraction boundary is unambiguous. + * `[Base]\n{base}\n\n[System]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}` + * with any section omitted when absent. Extraction runs in reverse producer + * order so that each `lastIndexOf` search operates on the full input and each + * extraction boundary is unambiguous. * - * Four extraction passes before Base/System parsing: + * Five extraction passes: * * 1. **Canvas** (`[Channel Canvas]`): appended last by `with_canvas()`. * - Start-of-string: canvas-only input. @@ -80,18 +77,23 @@ export function parsePromptText(text: string): { * 2. **Core** (`[Agent Memory — core]`): appended before canvas by `with_core()`. * Same two cases, same last-occurrence guard. * - * 3. **Base/System**: remainder after canvas and core extraction. + * 3. **Team Instructions** (`[Team Instructions]`): appended before core by + * `with_team()` in `buzz-acp/src/pool.rs`. Same two cases (start-of-string + * or `\n\n[Team Instructions]\n` inline), same last-occurrence guard. Output + * position: after System, before Core Memory. + * + * 4. **Base/System**: remainder after the three top-level section extractions. * Split on the first `\n[System]\n` boundary; no embedded `[...]` line * inside a body can start a new section. * - * 4. **Team Instructions**: if the `System` body contains the exact canonical - * delimiter `\n\n---\n# Team Instructions\n` (produced by `compose_prompt()`), - * the body is split at the **last** occurrence of that boundary (same - * last-occurrence guard as canvas and core). The text before becomes the - * `System` body; the text after becomes a `Team Instructions` section - * inserted immediately after `System`. Non-canonical lookalikes (bare `---` - * without the heading, a `# Team Instructions` on a different line, or only - * a single preceding newline) are kept literal inside `System`. + * 5. **Legacy Team Instructions** (backward compat): if the `System` body + * contains the exact canonical delimiter `\n\n---\n# Team Instructions\n` + * (produced by the now-removed `compose_prompt()` in buzz-persona), the body + * is split at the **last** occurrence of that boundary. The text before + * becomes the `System` body; the text after becomes a `Team Instructions` + * section inserted immediately after `System`. Non-canonical lookalikes + * (bare `---` without the heading, a `# Team Instructions` on a different + * line, or only a single preceding newline) are kept literal inside `System`. */ export function parseSystemPromptSections( systemPrompt: string, @@ -133,7 +135,29 @@ export function parseSystemPromptSections( } } - // ── 3. Parse Base/System from the remaining prefix ──────────────────────── + // ── 3. Extract [Team Instructions] (modern runtime framing) ───────────── + // with_team() in buzz-acp/src/pool.rs appends "\n\n[Team Instructions]\n{instructions}" + // after [System] and before core/canvas. Same two cases as canvas/core: + // start-of-string (team-only input) or the inline double-newline marker + // (last occurrence guards against embedded lookalikes preceded by a single \n). + const TEAM_HEADER = "[Team Instructions]"; + const TEAM_MARKER_INLINE = `\n\n${TEAM_HEADER}\n`; + let modernTeamBody: string | null = null; + + if (remainder.startsWith(`${TEAM_HEADER}\n`)) { + modernTeamBody = remainder.slice(`${TEAM_HEADER}\n`.length).trim(); + remainder = ""; + } else { + const lastTeam = remainder.lastIndexOf(TEAM_MARKER_INLINE); + if (lastTeam !== -1) { + modernTeamBody = remainder + .slice(lastTeam + TEAM_MARKER_INLINE.length) + .trim(); + remainder = remainder.slice(0, lastTeam); + } + } + + // ── 4. Parse Base/System from the remaining prefix ──────────────────────── // The canonical team-instructions delimiter produced by compose_prompt() in // buzz-persona/src/resolve.rs: // format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}") @@ -181,7 +205,9 @@ export function parseSystemPromptSections( } } - // ── 4. Append core and canvas sections in producer order ────────────────── + // ── 5. Append team (modern), core, and canvas sections in producer order ── + if (modernTeamBody) + sections.push({ title: "Team Instructions", body: modernTeamBody }); if (coreBody) sections.push({ title: "Core Memory", body: coreBody }); if (canvasBody) sections.push({ title: "Channel Canvas", body: canvasBody }); diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index 730594ffd..44ff609c9 100644 --- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -418,7 +418,7 @@ test.describe("observer feed screenshots", () => { method: "session/new", params: { systemPrompt: - "[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n---\n# Team Instructions\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", + "[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n[Team Instructions]\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", }, }, }, @@ -698,7 +698,7 @@ test.describe("observer feed screenshots", () => { method: "session/new", params: { systemPrompt: - "[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n---\n# Team Instructions\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", + "[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n[Team Instructions]\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", }, }, }, From 596386ee55d1516bc3d88922c44b9067a113a28e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 24 Jul 2026 09:11:09 -0700 Subject: [PATCH 2/4] feat(node): add Windows managed Node.js fallback (win-x64 + win-arm64) (#2661) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .../commands/agent_discovery/managed_node.rs | 327 ++++++++++++++++-- .../src/managed_agents/managed_node_paths.rs | 37 +- 2 files changed, 329 insertions(+), 35 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index c0e1b6fee..2d7e13d1d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -43,11 +43,27 @@ const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtif sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d", }); +#[cfg(all(target_os = "windows", target_arch = "x86_64"))] +const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { + platform: "win-x64", + filename: "node-v24.11.0-win-x64.zip", + sha256: "1054540bce22b54ec7e50ebc078ec5d090700a77657607a58f6a64df21f49fdd", +}); + +#[cfg(all(target_os = "windows", target_arch = "aarch64"))] +const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { + platform: "win-arm64", + filename: "node-v24.11.0-win-arm64.zip", + sha256: "12d3b1aa9696b7411e115a4fa2aef57f95560b5ee16bb62cd69843e535ec72be", +}); + #[cfg(not(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "macos", target_arch = "x86_64"), all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64") + all(target_os = "linux", target_arch = "aarch64"), + all(target_os = "windows", target_arch = "x86_64"), + all(target_os = "windows", target_arch = "aarch64") )))] const MANAGED_NODE_ARTIFACT: Option = None; @@ -180,7 +196,12 @@ fn install_managed_node_runtime( extract_managed_node_archive(&archive_path, &temp_dir, artifact.filename)?; let _ = std::fs::remove_file(&archive_path); - let extracted_dir = temp_dir.join(artifact.filename.trim_end_matches(".tar.gz")); + let extracted_dir = temp_dir.join( + artifact + .filename + .trim_end_matches(".tar.gz") + .trim_end_matches(".zip"), + ); let source_dir = if extracted_dir.is_dir() { extracted_dir } else { @@ -277,22 +298,106 @@ fn extract_managed_node_archive( dest_dir: &std::path::Path, filename: &str, ) -> Result<(), String> { - if !filename.ends_with(".tar.gz") { - return Err(format!("unsupported managed Node.js archive: {filename}")); - } - let file = - std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?; - let decoder = flate2::read::GzDecoder::new(file); - let mut archive = tar::Archive::new(decoder); - validate_managed_node_archive_entries(&mut archive)?; + if filename.ends_with(".tar.gz") { + let file = + std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?; + let decoder = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + validate_managed_node_archive_entries(&mut archive)?; - let file = std::fs::File::open(archive_path) - .map_err(|e| format!("open Node.js archive for extraction: {e}"))?; - let decoder = flate2::read::GzDecoder::new(file); - let mut archive = tar::Archive::new(decoder); - archive - .unpack(dest_dir) - .map_err(|e| format!("extract Node.js archive: {e}")) + let file = std::fs::File::open(archive_path) + .map_err(|e| format!("open Node.js archive for extraction: {e}"))?; + let decoder = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + archive + .unpack(dest_dir) + .map_err(|e| format!("extract Node.js archive: {e}")) + } else if filename.ends_with(".zip") { + let file = + std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?; + let mut archive = + zip::ZipArchive::new(file).map_err(|e| format!("read Node.js zip archive: {e}"))?; + validate_managed_node_zip_entries(&archive)?; + extract_managed_node_zip(&mut archive, dest_dir) + } else { + Err(format!("unsupported managed Node.js archive: {filename}")) + } +} + +/// Validate ZIP entry names using platform-neutral string logic. +/// +/// `std::path::Path` is intentionally avoided: its `is_absolute()` and +/// `Component` parsing use BUILD-HOST grammar, so `/etc/passwd` is not +/// `is_absolute()` on Windows (no drive prefix), causing the check to lie on +/// the platform this guard exists to protect. Instead we apply pure string +/// rules that produce identical results on every host: +/// +/// - Unix-rooted: starts with `/` +/// - Windows-rooted: starts with `\`, has a drive prefix (`X:`), or is UNC +/// (`\\` / `//`) +/// - Traversal: any component that is `..` when split on EITHER `/` or `\` +fn validate_managed_node_zip_entries( + archive: &zip::ZipArchive, +) -> Result<(), String> { + for i in 0..archive.len() { + let name = archive + .name_for_index(i) + .ok_or_else(|| format!("Node.js zip entry {i}: missing name"))?; + + // Absolute-path checks (platform-neutral). + if name.starts_with('/') || name.starts_with('\\') { + return Err(format!("Node.js zip contains absolute path: {name}")); + } + // Drive prefix: one ASCII letter followed by ':' + if name.len() >= 2 && name.as_bytes()[1] == b':' && name.as_bytes()[0].is_ascii_alphabetic() + { + return Err(format!("Node.js zip contains absolute path: {name}")); + } + // UNC prefix: // or \\ (covered by starts_with checks above for \\, + // and // is caught by starts_with('/') then a second '/' — belt + suspenders). + // (Already caught by the starts_with checks above; explicit for clarity.) + + // Traversal: split on both separators and check each component. + let has_traversal = name.split(['/', '\\']).any(|component| component == ".."); + if has_traversal { + return Err(format!("Node.js zip contains path traversal: {name}")); + } + } + Ok(()) +} + +fn extract_managed_node_zip( + archive: &mut zip::ZipArchive, + dest_dir: &std::path::Path, +) -> Result<(), String> { + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("Node.js zip entry {i}: {e}"))?; + let outpath = match entry.enclosed_name() { + Some(p) => dest_dir.join(p), + None => { + return Err(format!( + "Node.js zip contains unsafe path: {}", + entry.name() + )) + } + }; + if entry.is_dir() { + std::fs::create_dir_all(&outpath) + .map_err(|e| format!("create dir in Node.js zip: {e}"))?; + } else { + if let Some(parent) = outpath.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create parent dir in Node.js zip: {e}"))?; + } + let mut out = std::fs::File::create(&outpath) + .map_err(|e| format!("create file in Node.js zip: {e}"))?; + std::io::copy(&mut entry, &mut out) + .map_err(|e| format!("extract file in Node.js zip: {e}"))?; + } + } + Ok(()) } fn validate_managed_node_archive_entries( @@ -325,15 +430,36 @@ fn validate_managed_node_archive_entries( } fn verify_node_tree(dir: &std::path::Path) -> Result<(), String> { - let node = dir.join("bin").join("node"); - let npm = dir.join("bin").join("npm"); - if !node.is_file() { - return Err("Node.js archive missing bin/node".to_string()); + #[cfg(windows)] + { + // Windows zip layout: node.exe + npm.cmd + npm (POSIX sh shim) at archive root + let node = dir.join("node.exe"); + let npm_cmd = dir.join("npm.cmd"); + let npm = dir.join("npm"); + if !node.is_file() { + return Err("Node.js archive missing node.exe".to_string()); + } + if !npm_cmd.is_file() { + return Err("Node.js archive missing npm.cmd".to_string()); + } + if !npm.is_file() { + return Err("Node.js archive missing npm".to_string()); + } + Ok(()) } - if !npm.is_file() { - return Err("Node.js archive missing bin/npm".to_string()); + #[cfg(not(windows))] + { + // Unix tarball layout: bin/node + bin/npm + let node = dir.join("bin").join("node"); + let npm = dir.join("bin").join("npm"); + if !node.is_file() { + return Err("Node.js archive missing bin/node".to_string()); + } + if !npm.is_file() { + return Err("Node.js archive missing bin/npm".to_string()); + } + Ok(()) } - Ok(()) } // ── managed npm adapter installs ────────────────────────────────────────────── @@ -466,4 +592,157 @@ mod tests { "'/tmp/Buzz'\\''s Node'" ); } + + // ── zip validation tests ────────────────────────────────────────────────── + + /// Build an in-memory zip archive with the supplied entry names and return + /// a temporary file containing it (zip::ZipArchive requires Seek). + fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile { + let mut buf: Vec = Vec::new(); + { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default(); + for name in entry_names { + writer.start_file(*name, opts).unwrap(); + } + writer.finish().unwrap(); + } + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, &buf).unwrap(); + tmp + } + + #[test] + fn test_validate_zip_accepts_normal_entries() { + let tmp = make_zip_with_entries(&[ + "node-v24.11.0-win-x64/node.exe", + "node-v24.11.0-win-x64/npm.cmd", + "node-v24.11.0-win-x64/npm", + ]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + assert!(validate_managed_node_zip_entries(&archive).is_ok()); + } + + #[test] + fn test_validate_zip_rejects_absolute_path() { + let tmp = make_zip_with_entries(&["/etc/passwd"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); + } + + #[test] + fn test_validate_zip_rejects_path_traversal() { + let tmp = make_zip_with_entries(&["../../../etc/passwd"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("path traversal"), + "expected 'path traversal' in: {err}" + ); + } + + #[test] + fn test_validate_zip_rejects_backslash_rooted() { + // Windows-style absolute path using backslash — must reject on every host. + let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); + } + + #[test] + fn test_validate_zip_rejects_drive_prefix() { + // Windows drive-letter absolute path — must reject on every host. + let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); + } + + #[test] + fn test_validate_zip_rejects_backslash_traversal() { + // Path traversal using Windows separator — must reject on every host. + let tmp = make_zip_with_entries(&["node-v24.11.0-win-x64\\..\\..\\evil"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("path traversal"), + "expected 'path traversal' in: {err}" + ); + } + + // ── verify_node_tree layout tests ───────────────────────────────────────── + + #[test] + fn test_verify_node_tree_unix_layout_passes() { + let tmp = tempfile::TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), b"").unwrap(); + std::fs::write(bin.join("npm"), b"").unwrap(); + // On non-Windows the unix branch is active — this must pass. + #[cfg(not(windows))] + assert!(verify_node_tree(tmp.path()).is_ok()); + // On Windows the windows branch is active — unix layout must fail. + #[cfg(windows)] + assert!(verify_node_tree(tmp.path()).is_err()); + } + + #[test] + fn test_verify_node_tree_unix_layout_missing_npm_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), b"").unwrap(); + // npm intentionally absent + #[cfg(not(windows))] + { + let err = verify_node_tree(tmp.path()).unwrap_err(); + assert!(err.contains("bin/npm"), "err: {err}"); + } + } + + #[test] + fn test_verify_node_tree_windows_layout_passes() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); + std::fs::write(tmp.path().join("npm"), b"").unwrap(); + // On Windows the windows branch is active — this must pass. + #[cfg(windows)] + assert!(verify_node_tree(tmp.path()).is_ok()); + // On non-Windows the unix branch is active — windows-layout root files + // don't satisfy bin/node + bin/npm, so this must fail. + #[cfg(not(windows))] + assert!(verify_node_tree(tmp.path()).is_err()); + } + + #[test] + fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); + // npm POSIX shim intentionally absent + #[cfg(windows)] + { + let err = verify_node_tree(tmp.path()).unwrap_err(); + assert!(err.contains("npm"), "err: {err}"); + } + } } diff --git a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs index 6533f4fbe..e4b4c3696 100644 --- a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs +++ b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs @@ -11,22 +11,37 @@ pub(crate) fn buzz_managed_node_root() -> Option { } pub(crate) fn buzz_managed_node_bin_dir() -> Option { - let platform = match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") => "darwin-arm64", - ("macos", "x86_64") => "darwin-x64", - ("linux", "x86_64") => "linux-x64", - ("linux", "aarch64") => "linux-arm64", - _ => return None, - }; + let (platform, bin_subdir): (&str, Option<&str>) = + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => ("darwin-arm64", Some("bin")), + ("macos", "x86_64") => ("darwin-x64", Some("bin")), + ("linux", "x86_64") => ("linux-x64", Some("bin")), + ("linux", "aarch64") => ("linux-arm64", Some("bin")), + // Windows zips have node.exe + npm.cmd at the archive root — no bin/ subdir + ("windows", "x86_64") => ("win-x64", None), + ("windows", "aarch64") => ("win-arm64", None), + _ => return None, + }; buzz_managed_node_root().map(|root| { - root.join(BUZZ_MANAGED_NODE_VERSION) - .join(platform) - .join("bin") + let dir = root.join(BUZZ_MANAGED_NODE_VERSION).join(platform); + match bin_subdir { + Some(sub) => dir.join(sub), + None => dir, + } }) } pub(crate) fn buzz_managed_node_bin_path() -> Option { - buzz_managed_node_bin_dir().map(|bin| bin.join("node")) + buzz_managed_node_bin_dir().map(|bin| { + #[cfg(windows)] + { + bin.join("node.exe") + } + #[cfg(not(windows))] + { + bin.join("node") + } + }) } pub(crate) fn buzz_managed_npm_bin_dir() -> Option { From 9731cd818b21c7a3e1f56a319272eac6dada0555 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 24 Jul 2026 09:12:08 -0700 Subject: [PATCH 3/4] fix(onboarding): show real install errors and fix concurrent install state (#2658) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .../onboarding/ui/RuntimeErrorTooltip.tsx | 4 +- .../src/features/onboarding/ui/SetupStep.tsx | 89 +++++----- .../settings/ui/DoctorSettingsPanel.tsx | 122 ++++++------- desktop/src/shared/lib/installError.test.mjs | 44 +++++ desktop/src/testing/e2eBridge.ts | 39 ++++ desktop/tests/e2e/doctor-states.spec.ts | 129 ++++++++++++++ .../e2e/onboarding-agent-defaults.spec.ts | 167 ++++++++++++++++++ 7 files changed, 482 insertions(+), 112 deletions(-) diff --git a/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx b/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx index 15708d308..6a3d8ef31 100644 --- a/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx +++ b/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx @@ -42,7 +42,9 @@ export function RuntimeErrorTooltip({ side="bottom" sideOffset={12} > - {detail} + + {detail} + ); diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 96e7322d1..aa0248670 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -463,19 +463,56 @@ function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { } function RuntimeCard({ - installError, - isInstalling, - onInstall, + installResults, + onInstallResultsChange, runtime, }: { - installError: string | null; - isInstalling: boolean; - onInstall: () => void; + installResults: InstallResultsState; + onInstallResultsChange: React.Dispatch< + React.SetStateAction + >; runtime: AcpRuntimeCatalogEntry; }) { + // Each card owns its own mutation instance so concurrent installs on + // different cards each track their own isPending state and callbacks + // independently (react-query v5 per-mutate callbacks only fire for the + // latest mutate() call on a shared instance, silently dropping earlier ones). + const installMutation = useInstallAcpRuntimeMutation(); + const installError = installResults[runtime.id]?.error ?? null; + const isInstalling = installMutation.isPending; const isAvailable = runtime.availability === "available"; const isReady = runtimeIsReadyForOnboarding(runtime); + function handleInstall() { + onInstallResultsChange((current) => ({ + ...current, + [runtime.id]: { error: null, success: false }, + })); + + installMutation.mutate(runtime.id, { + onSuccess: (result) => { + onInstallResultsChange((current) => ({ + ...current, + [runtime.id]: result.success + ? { error: null, success: true } + : { + error: getInstallErrorMessage(result.steps), + success: false, + }, + })); + }, + onError: (error) => { + onInstallResultsChange((current) => ({ + ...current, + [runtime.id]: { + error: error instanceof Error ? error.message : "Install failed.", + success: false, + }, + })); + }, + }); + } + return ( {!isAvailable && runtimeDetailText(runtime) ? ( @@ -517,7 +554,7 @@ function RuntimeCard({ {installError ? ( ({ - ...current, - [runtimeId]: { error: null, success: false }, - })); - - installMutation.mutate(runtimeId, { - onSuccess: (result) => { - onInstallResultsChange((current) => ({ - ...current, - [runtimeId]: result.success - ? { error: null, success: true } - : { error: getInstallErrorMessage(result.steps), success: false }, - })); - }, - onError: (error) => { - onInstallResultsChange((current) => ({ - ...current, - [runtimeId]: { - error: error instanceof Error ? error.message : "Install failed.", - success: false, - }, - })); - }, - }); - } return (
@@ -606,13 +615,9 @@ function RuntimeProvidersSection({
{orderedItems.map((runtime) => ( handleInstall(runtime.id)} + onInstallResultsChange={onInstallResultsChange} runtime={runtime} /> ))} diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index cee156ece..7a0186a7b 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -279,22 +279,56 @@ function RuntimeHeader({ } function RuntimeRow({ - installError, - installSuccess, - isInstalling, - onInstall, + resetEpoch, runtime, }: { - installError: string | null; - installSuccess: boolean; - isInstalling: boolean; - onInstall: () => void; + resetEpoch: number; runtime: AcpRuntimeCatalogEntry; }) { const [terminalLaunchMethodId, setTerminalLaunchMethodId] = React.useState< string | null >(null); const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false); + // Each row owns its mutation instance so concurrent installs each track + // their own isPending / result state independently. + const installMutation = useInstallAcpRuntimeMutation(); + const [installResult, setInstallResult] = React.useState<{ + success: boolean; + error: string | null; + } | null>(null); + // Clear stale install results when the parent triggers a catalog refresh + // (Check again) — the runtime may now be healthy and stale failure state + // would linger because keyed rows don't remount on refetch. + // biome-ignore lint/correctness/useExhaustiveDependencies: resetEpoch is an intentional trigger only; its value is not consumed in the effect body + React.useEffect(() => { + setInstallResult(null); + }, [resetEpoch]); + const isInstalling = installMutation.isPending; + const installError = installResult?.error ?? null; + const installSuccess = installResult?.success ?? false; + + function handleInstall() { + setInstallResult(null); + installMutation.mutate(runtime.id, { + onSuccess: (result) => { + if (result.success) { + setInstallResult({ success: true, error: null }); + } else { + setInstallResult({ + success: false, + error: getInstallErrorMessage(result.steps), + }); + } + }, + onError: (error) => { + setInstallResult({ + success: false, + error: error instanceof Error ? error.message : "Install failed.", + }); + }, + }); + } + const canConnectAccount = runtime.availability === "available" && runtime.authStatus.status === "logged_out"; @@ -352,7 +386,7 @@ function RuntimeRow({ setIsUpdateWarningOpen(true); return; } - onInstall(); + handleInstall(); }} runtime={runtime} /> @@ -372,7 +406,10 @@ function RuntimeRow({

) : null} {installError ? ( -

+

{installError}

) : null} @@ -410,7 +447,7 @@ function RuntimeRow({ Cancel Update @@ -491,59 +528,9 @@ export function DoctorSettingsPanel() { [runtimesQuery.data], ); const isRefreshing = runtimesQuery.isFetching; - const installMutation = useInstallAcpRuntimeMutation(); - const [installResults, setInstallResults] = React.useState< - Record - >({}); - // Per-runtime installing state: tracks which runtime IDs have an in-flight - // install so concurrent installs each show their own spinner correctly. - const [installingIds, setInstallingIds] = React.useState>( - new Set(), - ); - - function handleInstall(runtimeId: string) { - // Clear any previous result for this runtime before retrying. - setInstallResults((prev) => ({ - ...prev, - [runtimeId]: { success: false, error: null }, - })); - setInstallingIds((prev) => new Set(prev).add(runtimeId)); - - installMutation.mutate(runtimeId, { - onSuccess: (result) => { - if (result.success) { - setInstallResults((prev) => ({ - ...prev, - [runtimeId]: { success: true, error: null }, - })); - } else { - setInstallResults((prev) => ({ - ...prev, - [runtimeId]: { - success: false, - error: getInstallErrorMessage(result.steps), - }, - })); - } - }, - onError: (error) => { - setInstallResults((prev) => ({ - ...prev, - [runtimeId]: { - success: false, - error: error instanceof Error ? error.message : "Install failed.", - }, - })); - }, - onSettled: () => { - setInstallingIds((prev) => { - const next = new Set(prev); - next.delete(runtimeId); - return next; - }); - }, - }); - } + // Incremented each time the user clicks "Check again" so RuntimeRow + // useEffect clears stale install results from before the refresh. + const [resetEpoch, setResetEpoch] = React.useState(0); return (
{ - setInstallResults({}); + setResetEpoch((e) => e + 1); void runtimesQuery.refetch(); void gitBashQuery.refetch(); }} @@ -598,11 +585,8 @@ export function DoctorSettingsPanel() {
{runtimes.map((runtime) => ( handleInstall(runtime.id)} + resetEpoch={resetEpoch} runtime={runtime} /> ))} diff --git a/desktop/src/shared/lib/installError.test.mjs b/desktop/src/shared/lib/installError.test.mjs index 232ac178c..c6b51186a 100644 --- a/desktop/src/shared/lib/installError.test.mjs +++ b/desktop/src/shared/lib/installError.test.mjs @@ -67,3 +67,47 @@ test("getInstallErrorMessage: failed step with empty stderr falls back to stdout ]); assert.match(message, /some stdout output/); }); + +test("getInstallErrorMessage: hint and step detail are separated by double newline for whitespace-pre-line rendering", () => { + const hint = "Git Bash is required. Install it from git-scm.com."; + const message = getInstallErrorMessage([ + { + step: "shell", + command: "bash -l -c 'npm install'", + success: false, + stdout: "", + stderr: "bash: command not found", + exitCode: 127, + hint, + }, + ]); + assert.ok( + message.includes("\n\n"), + "hint and step detail should be separated by a blank line", + ); + assert.ok(message.startsWith(hint)); +}); + +test("getInstallErrorMessage: only reports the last (failing) step when multiple steps present", () => { + const message = getInstallErrorMessage([ + { + step: "node", + command: "node --version", + success: true, + stdout: "v20.0.0", + stderr: "", + exitCode: 0, + }, + { + step: "adapter", + command: "npm install -g @agentclientprotocol/claude-code-acp", + success: false, + stdout: "", + stderr: "npm ERR! code E404", + exitCode: 1, + }, + ]); + assert.match(message, /Step "adapter" failed:/); + assert.match(message, /npm ERR! code E404/); + assert.doesNotMatch(message, /Step "node"/); +}); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1ae2c7d83..a7d58e70c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -185,6 +185,18 @@ type E2eConfig = { * Call N returns results[N]; when exhausted the last entry repeats. * Takes precedence over `installAcpRuntimeResult`. */ installAcpRuntimeResults?: RawInstallRuntimeResult[]; + /** Per-runtime install configuration keyed by runtimeId. + * When a runtimeId matches, its entry overrides the global + * installAcpRuntime* fields for that specific runtime. */ + installAcpRuntimeByRuntime?: Record< + string, + { + delayMs?: number; + result?: RawInstallRuntimeResult; + /** Call-order sequence — same semantics as installAcpRuntimeResults. */ + results?: RawInstallRuntimeResult[]; + } + >; managedAgentPrereqs?: { acp?: MockCommandAvailability; mcp?: MockCommandAvailability; @@ -7060,6 +7072,8 @@ async function handleConnectAcpRuntime( // Per-page install call counter. Reset each test run because this module is // re-evaluated via addInitScript, so the counter starts at 0 for every test. let installCallCount = 0; +/** Per-runtime call counters for `installAcpRuntimeByRuntime` sequences. */ +const installCallCountByRuntime: Record = {}; let addChannelMembersCallCount = 0; let mockGlobalAgentConfig: { env_vars: Record; @@ -7080,6 +7094,31 @@ async function handleInstallAcpRuntime( }, config: E2eConfig | undefined, ): Promise { + const runtimeId = args.runtimeId ?? ""; + const perRuntime = config?.mock?.installAcpRuntimeByRuntime?.[runtimeId]; + + if (perRuntime) { + const delayMs = perRuntime.delayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } + const seq = perRuntime.results; + if (seq && seq.length > 0) { + const idx = Math.min( + installCallCountByRuntime[runtimeId] ?? 0, + seq.length - 1, + ); + installCallCountByRuntime[runtimeId] = idx + 1; + const result = seq[idx]; + if (result.success) mockInstallCompleted = true; + return result; + } + if (perRuntime.result) { + if (perRuntime.result.success) mockInstallCompleted = true; + return perRuntime.result; + } + } + const delayMs = config?.mock?.installAcpRuntimeDelayMs ?? 0; if (delayMs > 0) { await new Promise((resolve) => window.setTimeout(resolve, delayMs)); diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 1a724f719..dd2cf8328 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -735,4 +735,133 @@ test.describe("Doctor panel state screenshots", () => { await expect(loading).toBeVisible(); await expect(loading).toContainText("Codex installing"); }); + + /** + * 08 — concurrent installs each keep their own spinner/result state; + * stale install failure is cleared when Check again fires (F1 fix). + * + * Flow: + * - Claude (400ms delay) → failure + * - Codex (100ms delay) → success + * Both started before either settles. + * After both settle: claude shows failure, codex shows success banner. + * Click Check again → both rows lose stale state (claude error gone). + */ + test("08-concurrent-installs-and-stale-clear", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + ...CLAUDE_AVAILABLE_LOGGED_IN, + availability: "adapter_missing", + command: null, + binary_path: null, + can_auto_install: true, + auth_status: { status: "unknown" }, + }, + { + ...CODEX_NOT_INSTALLED, + can_auto_install: true, + node_required: false, + }, + GOOSE_AVAILABLE, + BUZZ_AGENT_AVAILABLE, + ], + installAcpRuntimeByRuntime: { + claude: { + delayMs: 400, + result: { + success: false, + steps: [ + { + step: "adapter", + command: "npm install -g @agentclientprotocol/claude-agent-acp", + success: false, + stdout: "", + stderr: + "npm ERR! code EACCES\nnpm ERR! syscall mkdir\nnpm ERR! path /usr/local\n\nHint: Check prefix permissions.", + exit_code: 1, + }, + ], + }, + }, + codex: { + delayMs: 100, + result: { + success: true, + steps: [ + { + step: "adapter", + command: "npm install -g @zed-industries/codex-acp", + success: true, + stdout: "added 1 package", + stderr: "", + exit_code: 0, + }, + ], + }, + }, + }, + // After the catalog refresh (triggered by a successful install or Check + // again), all runtimes report healthy so stale errors must clear. + acpRuntimesCatalogAfterInstall: [ + { + ...CLAUDE_AVAILABLE_LOGGED_IN, + availability: "available", + }, + { + ...CODEX_NOT_INSTALLED, + availability: "available", + command: "codex-acp", + binary_path: "/usr/local/bin/codex-acp", + auth_status: { status: "logged_in" }, + }, + GOOSE_AVAILABLE, + BUZZ_AGENT_AVAILABLE, + ], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + const claudeRow = page.getByTestId("doctor-runtime-claude"); + const codexRow = page.getByTestId("doctor-runtime-codex"); + await expect(claudeRow).toBeVisible({ timeout: 10_000 }); + await expect(codexRow).toBeVisible(); + + const claudeToggle = page.getByTestId("doctor-runtime-toggle-claude"); + const codexToggle = page.getByTestId("doctor-runtime-toggle-codex"); + + // Start both installs before either settles. + await claudeToggle.click(); + await codexToggle.click(); + + // Codex settles first (shorter delay): toggle flips on, no error on codex. + // The catalog refresh triggered by codex's success immediately returns + // availability === "available", so the transient "installed. Checking..." + // banner is replaced by the stable isOn state — assert the toggle instead. + await expect(codexToggle).toBeChecked({ timeout: 3_000 }); + await expect( + page.getByTestId("doctor-runtime-install-error-codex"), + ).toHaveCount(0); + + // Claude settles (after its longer delay): failure error visible with + // multiline stderr. Codex toggle must still be on — unaffected by claude. + const claudeError = page.getByTestId("doctor-runtime-install-error-claude"); + await expect(claudeError).toBeVisible({ timeout: 3_000 }); + await expect(claudeError).toContainText("npm ERR!"); + await expect(codexToggle).toBeChecked(); + + // Click Check again — epoch increments, RuntimeRow useEffect clears + // local installResult state, so the stale claude error disappears. + await page.getByRole("button", { name: "Check again" }).click(); + await expect(claudeError).toHaveCount(0, { timeout: 5_000 }); + // Codex toggle stays on (catalog still reports available after refresh). + await expect(codexToggle).toBeChecked({ timeout: 5_000 }); + + await claudeRow.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await claudeRow.screenshot({ + path: `${SHOTS}/08-concurrent-installs-and-stale-clear.png`, + }); + }); }); diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index f8e6322ed..e1d8b11b6 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -635,3 +635,170 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); await expect.poll(() => readSavedRuntime(page)).toBe("codex"); }); + +/** + * Two installs started concurrently — claude fails with a multiline error + * (rich hint+stderr in the tooltip) while codex succeeds. Each card must + * keep its own independent spinner and its own terminal result; neither card + * may show the other's outcome. + * + * This is the behavioral regression test for the per-card mutation fix + * (Bug B) and the multiline tooltip fix (Bug A / F3 from Thufir pass 1). + */ +test("concurrent installs each keep their own state — one fails, one succeeds", async ({ + page, +}) => { + // Realistic 512-head + 1024-tail shape: many short lines followed by one + // long unbroken Windows path. This exercises both overflow axes: + // • vertical: enough lines to exceed max-h-48 (192px at ~16px/line) + // • horizontal: the long path has no spaces, so only break-words prevents + // scrollWidth > clientWidth. + const longWindowsPath = + "C:\\Users\\willp\\AppData\\Roaming\\npm\\node_modules\\@agentclientprotocol\\claude-agent-acp\\dist\\bin\\claude-agent-acp.exe"; + const multilineError = [ + "npm ERR! code EACCES", + "npm ERR! syscall mkdir", + "npm ERR! path C:\\Users\\willp\\AppData\\Roaming\\npm", + "npm ERR! errno -4048", + "npm ERR! Error: EACCES: permission denied, mkdir 'C:\\Users\\willp\\AppData\\Roaming\\npm'", + "npm ERR! { [Error: EACCES: permission denied, mkdir 'C:\\Users\\willp\\AppData\\Roaming\\npm']", + "npm ERR! errno: -4048,", + "npm ERR! code: 'EACCES',", + "npm ERR! syscall: 'mkdir',", + "npm ERR! path: 'C:\\\\Users\\\\willp\\\\AppData\\\\Roaming\\\\npm' }", + "npm ERR!", + "npm ERR! The operation was rejected by your operating system.", + "npm ERR! It is likely you do not have the permissions to access this file as the current user", + "npm ERR!", + `npm ERR! If you believe this might be a permissions issue, please double-check the`, + `npm ERR! permissions of the file and its containing directories, or try running`, + `npm ERR! the command again as root/Administrator.`, + "", + `Hint: Run as Administrator or change npm prefix: npm config set prefix ${longWindowsPath}`, + ].join("\n"); + const claudeNotInstalled = runtime("claude", "adapter_missing", { + status: "unknown", + }); + const codexNotInstalled = runtime("codex", "adapter_missing", { + status: "unknown", + }); + await installMockBridge( + page, + { + acpRuntimesCatalog: [claudeNotInstalled, codexNotInstalled], + // Claude: long delay then failure with multiline stderr + hint. + // Codex: short delay then success. + // Per-runtime config lets both be in flight simultaneously. + installAcpRuntimeByRuntime: { + claude: { + delayMs: 600, + result: { + success: false, + steps: [ + { + step: "adapter", + command: "npm install -g @agentclientprotocol/claude-agent-acp", + success: false, + stdout: "", + stderr: multilineError, + exit_code: 1, + }, + ], + }, + }, + codex: { + delayMs: 200, + result: { + success: true, + steps: [ + { + step: "adapter", + command: "npm install -g @zed-industries/codex-acp", + success: true, + stdout: "added 1 package", + stderr: "", + exit_code: 0, + }, + ], + }, + }, + }, + acpRuntimesCatalogAfterInstall: [ + runtime("claude", "adapter_missing", { status: "unknown" }), + runtime("codex", "available", { status: "logged_in" }), + ], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + const claudeInstall = page.getByTestId("onboarding-runtime-install-claude"); + const codexInstall = page.getByTestId("onboarding-runtime-install-codex"); + + // Start both installs before either settles. + await claudeInstall.click(); + await codexInstall.click(); + + // While in flight: both install buttons must be absent (no duplicate clicks). + await expect(claudeInstall).toHaveCount(0); + await expect(codexInstall).toHaveCount(0); + + // Codex settles first (shorter delay): success indicator, no error. + await expect(page.getByTestId("onboarding-runtime-ready-codex")).toBeVisible({ + timeout: 3_000, + }); + await expect(page.getByTestId("onboarding-runtime-error-codex")).toHaveCount( + 0, + ); + + // Claude still in flight: its install button must still be absent. + await expect(claudeInstall).toHaveCount(0); + + // Claude settles: failure error visible; codex still shows ready (not reset). + const claudeError = page.getByTestId("onboarding-runtime-error-claude"); + await expect(claudeError).toBeVisible({ timeout: 3_000 }); + await expect( + page.getByTestId("onboarding-runtime-ready-codex"), + ).toBeVisible(); + await expect(page.getByTestId("onboarding-runtime-error-codex")).toHaveCount( + 0, + ); + + // The error trigger has the full aria-label (label + detail). + await expect(claudeError).toHaveAttribute("aria-label", /npm ERR!/); + // Open the tooltip and verify the detail span handles overflow correctly: + // • vertical overflow exists and is scrollable (max-h-48 + overflow-y-auto) + // • no horizontal overflow (break-words forces the long unbroken path to wrap) + await claudeError.focus(); + const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toBeVisible({ timeout: 2_000 }); + await expect(tooltip).toContainText("npm ERR! code EACCES"); + await expect(tooltip).toContainText("Hint: Run as Administrator"); + + // Locate the scroll container using page-level locator since Radix portals + // can place content outside the tooltip role element's subtree in the DOM. + // Use .first() because Radix keeps a hidden duplicate in the light DOM. + const detailSpan = page.locator("span.overflow-y-auto").first(); + await expect(detailSpan).toBeVisible(); + + // Vertical: scrollHeight must exceed clientHeight (content taller than max-h-48). + // Scroll position must advance when set, proving scrollability. + const isVerticallyScrollable = await detailSpan.evaluate((el) => { + return el.scrollHeight > el.clientHeight; + }); + expect(isVerticallyScrollable).toBe(true); + + // Confirm scroll position can actually advance. + await detailSpan.evaluate((el) => { + el.scrollTop = 9999; + }); + const scrolledDown = await detailSpan.evaluate((el) => el.scrollTop > 0); + expect(scrolledDown).toBe(true); + + // Horizontal: break-words must prevent horizontal overflow. + const hasHorizontalOverflow = await detailSpan.evaluate((el) => { + return el.scrollWidth > el.clientWidth; + }); + expect(hasHorizontalOverflow).toBe(false); +}); From b78a684cfa997bbffbc86ac9c311f4f7af25d11a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 24 Jul 2026 09:19:31 -0700 Subject: [PATCH 4/4] ci: add Windows and Linux canary workflows with caching (#2642) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .github/workflows/linux-canary.yml | 227 ++++++++++++++++++++++ .github/workflows/signed-macos-canary.yml | 29 ++- .github/workflows/windows-canary.yml | 159 +++++++++++++++ 3 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/linux-canary.yml create mode 100644 .github/workflows/windows-canary.yml diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml new file mode 100644 index 000000000..18d476e40 --- /dev/null +++ b/.github/workflows/linux-canary.yml @@ -0,0 +1,227 @@ +name: Linux Canary + +# Produces unsigned Linux .deb and .AppImage packages from main without +# creating a tag, GitHub Release, or auto-updater artifact. Artifacts are +# available as a short-lived GitHub Actions artifact for explicit testing. +# +# Design notes vs. signed-macos-canary.yml: +# - fix-appimage.sh is run without signing env vars; the script detects +# their absence and skips re-signing, repacking only (documented inline). +# - mold linker added (rui314/setup-mold) to reduce link time, matching +# the Linux Rust CI jobs in ci.yml. +# - pnpm store restore/save pattern mirrors ci.yml:149-196. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build Linux canary + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + timeout-minutes: 60 + permissions: + contents: read + env: + # AppImage tools are themselves AppImages; containers lack FUSE so we + # must use the extract-and-run fallback. + APPIMAGE_EXTRACT_AND_RUN: "1" + defaults: + run: + shell: bash + steps: + - name: Require main + env: + SOURCE_REF: ${{ github.ref }} + run: | + if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then + echo "::error::Canary builds must run from main; got $SOURCE_REF" + exit 1 + fi + + - name: Install system dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + apt-get update \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 + apt-get install -y --no-install-recommends \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + -o DPkg::Lock::Timeout=120 \ + build-essential \ + ca-certificates \ + curl \ + desktop-file-utils \ + file \ + git \ + libasound2-dev \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + patchelf \ + pkg-config \ + squashfs-tools \ + wget \ + xdg-utils + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Mark workspace safe for git (containerized job) + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 + + # Rust cache covering both the workspace sidecar build and the Tauri + # crate build. shared-key scoped to linux-canary-release so canary runs + # warm each other without colliding with CI's debug-profile keys. + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: | + . + desktop/src-tauri + shared-key: linux-canary-release + + - name: Install appimagetool + run: | + case "$(uname -m)" in + x86_64) ARCH_SUFFIX="x86_64" ;; + aarch64) ARCH_SUFFIX="aarch64" ;; + *) + echo "::error::Unsupported architecture: $(uname -m)" + exit 1 + ;; + esac + wget -q --tries=3 --timeout=30 -O /tmp/appimagetool \ + "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${ARCH_SUFFIX}.AppImage" + if [[ "$ARCH_SUFFIX" == "x86_64" ]]; then + echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | sha256sum -c + else + echo "::error::No pinned SHA256 for appimagetool-${ARCH_SUFFIX} — add it before enabling this architecture" + exit 1 + fi + install -m 755 /tmp/appimagetool /usr/local/bin/appimagetool + wget -q --tries=3 --timeout=30 -O /tmp/appimage-runtime \ + "https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${ARCH_SUFFIX}" + echo "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/appimage-runtime" | sha256sum -c + install -D -m 644 /tmp/appimage-runtime /usr/local/lib/appimage-runtime + echo "APPIMAGETOOL_RUNTIME_FILE=/usr/local/lib/appimage-runtime" >> "$GITHUB_ENV" + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + + - name: Install desktop dependencies + run: just desktop-install-ci + + - name: Derive canary version + id: version + run: | + set -euo pipefail + BASE_VERSION=$(node -p "require('./desktop/package.json').version") + if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Desktop version '$BASE_VERSION' is not semver" + exit 1 + fi + VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building canary version $VERSION from $GITHUB_SHA" + + - name: Patch canary version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" + cd src-tauri && cargo update --workspace + + - name: Generate non-updating bundle config + run: | + cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' + { + "bundle": { + "createUpdaterArtifacts": false + } + } + JSON + + - name: Build sidecars + run: | + cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh + + - name: Build Linux Tauri app + run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --config src-tauri/tauri.canary.conf.json + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + + - name: Fix AppImage (remove infra libs, symlink system GStreamer) + # fix-appimage.sh checks for TAURI_SIGNING_PRIVATE_KEY and skips + # re-signing when absent, so no signing env vars are needed here. + # Repacking still runs, removing the Mesa/GLib/GStreamer conflict libs. + run: | + mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f) + if [[ ${#APPIMAGES[@]} -eq 0 ]]; then + echo "::error::No AppImage found to post-process" + exit 1 + fi + if [[ ${#APPIMAGES[@]} -gt 1 ]]; then + echo "::error::Expected exactly one AppImage, found ${#APPIMAGES[@]}: ${APPIMAGES[*]}" + exit 1 + fi + bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}" + + - name: Save pnpm store cache + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Locate Linux build artifacts + id: artifacts + run: | + set -euo pipefail + BUNDLE_DIR="desktop/src-tauri/target/release/bundle" + + DEB=$(find "$BUNDLE_DIR/deb" -name '*.deb' -type f | head -1) + if [[ -z "$DEB" ]]; then + echo "::error::No DEB found in $BUNDLE_DIR/deb" + exit 1 + fi + echo "deb=$DEB" >> "$GITHUB_OUTPUT" + + APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage' -type f | head -1) + if [[ -z "$APPIMAGE" ]]; then + echo "::error::No AppImage found in $BUNDLE_DIR/appimage" + exit 1 + fi + echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT" + + - name: Upload Linux canary packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-linux-canary-${{ github.sha }} + path: | + ${{ steps.artifacts.outputs.deb }} + ${{ steps.artifacts.outputs.appimage }} + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index 849e98623..fb0656028 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -28,12 +28,33 @@ jobs: exit 1 fi - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + # Rust cache covering both the workspace sidecar build and the Tauri + # crate build. shared-key scoped to macos-canary-release so canary runs + # warm each other without colliding with CI's debug-profile keys. + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: | + . + desktop/src-tauri + shared-key: macos-canary-release + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies run: just desktop-install-ci @@ -188,3 +209,9 @@ jobs: path: ${{ steps.artifact.outputs.path }} if-no-files-found: error retention-days: 7 + + - name: Save pnpm store cache + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml new file mode 100644 index 000000000..29f74fa0f --- /dev/null +++ b/.github/workflows/windows-canary.yml @@ -0,0 +1,159 @@ +name: Windows Canary + +# Produces an unsigned Windows NSIS installer from main without creating +# a tag, GitHub Release, or auto-updater artifact. The installer is available +# only as a short-lived GitHub Actions artifact for explicit testing. +# +# Design notes vs. signed-macos-canary.yml: +# - No mesh-llm: release-windows doesn't build it. +# - pnpm store restore/save pattern mirrors ci.yml:149-196. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build Windows canary + if: github.repository == 'block/buzz' + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: read + env: + TARGET: x86_64-pc-windows-msvc + steps: + - name: Require main + shell: bash + env: + SOURCE_REF: ${{ github.ref }} + run: | + if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then + echo "::error::Canary builds must run from main; got $SOURCE_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # The Windows runner ships with rustup preinstalled; rust-toolchain.toml + # in the repo root pins the channel (1.95.0) automatically. We only need + # to ensure the cross-compile target is registered; on windows-latest the + # host IS x86_64-pc-windows-msvc so this is typically a no-op. + - name: Add Rust target + shell: bash + run: rustup target add "$TARGET" + + # Rust cache covering both the workspace sidecar build and the Tauri + # crate build. shared-key scoped to windows-canary-release so canary + # runs warm each other without colliding with CI's debug-profile key + # (CI windows job does clippy/check, not --release). + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: | + . + desktop/src-tauri + shared-key: windows-canary-release + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + # Disable setup-node's built-in cache: we manage the pnpm store cache + # explicitly below (restore before install, save after) to mirror the + # pattern used by ci.yml and to keep caching logic consistent across + # all three canary workflows. + package-manager-cache: false + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + + - name: Install desktop dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Derive canary version + id: version + shell: bash + run: | + set -euo pipefail + BASE_VERSION=$(node -p "require('./desktop/package.json').version") + if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Desktop version '$BASE_VERSION' is not semver" + exit 1 + fi + VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building canary version $VERSION from $GITHUB_SHA" + + - name: Patch canary version + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" + cd src-tauri && cargo update --workspace + + - name: Generate non-updating bundle config + shell: bash + run: | + cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' + { + "bundle": { + "createUpdaterArtifacts": false + } + } + JSON + + - name: Build sidecars + shell: bash + run: | + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + + - name: Build Windows NSIS installer (unsigned) + shell: bash + run: cd desktop && pnpm tauri build --target "$TARGET" --bundles nsis --config src-tauri/tauri.canary.conf.json + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + + - name: Locate NSIS installer + id: artifact + shell: bash + run: | + set -euo pipefail + BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle" + EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1) + if [[ -z "$EXE" ]]; then + echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis" + exit 1 + fi + echo "exe=$EXE" >> "$GITHUB_OUTPUT" + + - name: Upload Windows canary installer + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-windows-canary-${{ github.sha }} + path: ${{ steps.artifact.outputs.exe }} + if-no-files-found: error + retention-days: 7 + + - name: Save pnpm store cache + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}