From e2fb0be390299f34a4fef9ca2a68869c72cddcd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 15:34:59 +0000 Subject: [PATCH 1/7] ci+docs: re-enable CI on main, fix line-ending pins and stale claims - ci.yml triggered on 'master' but the default branch is 'main', so build/test/smoke CI never ran on any push or PR. Point it at main. - .gitattributes: pin *.sh/*.zig to LF and *.ps1/*.bat to CRLF so a Windows checkout can't commit CRLF shebangs that break POSIX CI. - README: correct verified metrics (Zig lines 18,395->18,850, tests 198->213) and drop the unverifiable pass/skip split. - release.yml: fix stale docs/RELEASE_CHECKLIST.md path comment. - .gitignore: drop dead !bin/.gitkeep negation. - install.sh: stop suggesting a --version flag the shell lacks. https://claude.ai/code/session_01Sx5Tzwp8fTJR1chro59dhF --- .gitattributes | 11 +++++++++++ .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- .gitignore | 1 - README.md | 4 ++-- scripts/install.sh | 2 +- 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 176a458..1652a63 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,12 @@ +# Normalize text files to LF in the repo, but pin line endings where the +# platform demands a specific one. POSIX shells choke on CRLF shebangs; +# Windows batch/PowerShell scripts expect CRLF. * text=auto + +*.zig text eol=lf +*.sh text eol=lf +*.ps1 text eol=crlf +*.bat text eol=crlf + +*.png binary +*.ico binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5497b61..b308324 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: build-test-smoke: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb0b1fd..7ad2c27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ name: Release # Windows runner, smoke-tests the extracted binaries on Linux and macOS, # generates checksums, and uploads everything to a draft GitHub Release. # -# See docs/RELEASE_CHECKLIST.md for the human checklist that wraps this. +# See docs/guides/release-checklist.md for the human checklist that wraps this. on: push: diff --git a/.gitignore b/.gitignore index 3dc3b60..f1621dd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ dist/ # Compiled binary output bin/ -!bin/.gitkeep # Ignore any .o or .h files generated during build *.o diff --git a/README.md b/README.md index 427df96..d3269c1 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ Pick a theme in the desktop with `Ctrl+Shift+T`. The terminal palette AND the sh | | | | --- | --- | -| Zig lines | 18,395 | -| Unit tests | 198 (196 pass, 2 skip) | +| Zig lines | 18,850 | +| Unit tests | 213 (some skip on non-target platforms) | | Binaries | 4 | | Cross-built targets | 5 (windows-x86_64, linux-x86_64, linux-aarch64, macos-x86_64, macos-aarch64) | | Third-party deps | 0 | diff --git a/scripts/install.sh b/scripts/install.sh index 3207be4..ed40896 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -131,5 +131,5 @@ case ":$PATH:" in esac printf "Try it:\n" -printf " %s\n" "$INSTALL_DIR/ziggyzag --version 2>/dev/null || $INSTALL_DIR/ziggyzag" +printf " %s\n" "$INSTALL_DIR/ziggyzag # then type 'about' for version info" printf "\nDocs: https://github.com/$REPO\n" From ea0249a95d04e56ed97c24a53dd557eda47d78d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 15:42:02 +0000 Subject: [PATCH 2/7] fix: macOS job reaping, pipeline double-exec, provider redaction, JSON parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-platform / correctness fixes surfaced by a code audit (verified by static review; this environment has no Zig toolchain to compile-check): shell: - childHasExited only handled Linux/Windows, so macOS and BSD fell through to 'return false'. Background jobs were never marked done: finished children lingered as zombies and 'jobs' showed them Running forever. Add a libc waitpid(WNOHANG) branch that handles ECHILD without panicking. - tryRunNativePipeline detected an unsupported (redirected/empty) stage only mid-run, after earlier stages had already spawned, then fell back to the system shell and re-ran the WHOLE line — double-executing non-idempotent stages like the 'rm f' in 'rm f | grep x > out'. Pre-validate every stage before running any. (Safe: /backticks are rejected upstream.) - Collapse a dead duplicated runViaSystemShell branch. agentd: - providerAgentRun returned provider stdout/stderr verbatim while the README promises redacted output. Run redactSecretsAlloc over both (made pub) and add a 'redacted' flag, matching the file.read/rg.search/git.diff paths. desktop: - jsonStringValue used a single-byte backslash lookback to find a string's closing quote, so a value ending in a backslash (e.g. a Windows path "C:\\dev\\") was read as an unterminated string. Track escape state. https://claude.ai/code/session_01Sx5Tzwp8fTJR1chro59dhF --- apps/agentd/src/main.zig | 15 ++++++++-- apps/agentd/src/tools.zig | 6 ++-- apps/desktop/src/integration.zig | 16 +++++++++-- apps/shell/src/main.zig | 49 ++++++++++++++++++++++++++++---- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/apps/agentd/src/main.zig b/apps/agentd/src/main.zig index 68ce1c8..aeb1c8f 100644 --- a/apps/agentd/src/main.zig +++ b/apps/agentd/src/main.zig @@ -237,6 +237,15 @@ fn providerAgentRunJsonAlloc( const call = try callProviderCurl(allocator, io, env, config, endpoint, body); defer call.deinit(allocator); + // Provider stdout/stderr can echo secret-shaped strings (a key pasted into + // a prompt, an auth header bounced back in an error body). Redact both + // before they enter the envelope, matching the file.read / rg.search / + // git.diff tool paths and the "redacted output" posture in the README. + const response_redacted = try agentd.tools.redactSecretsAlloc(allocator, call.stdout); + defer response_redacted.deinit(allocator); + const stderr_redacted = try agentd.tools.redactSecretsAlloc(allocator, call.stderr); + defer stderr_redacted.deinit(allocator); + var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); try out.appendSlice(allocator, "{\"provider\":"); @@ -248,9 +257,11 @@ fn providerAgentRunJsonAlloc( try out.appendSlice(allocator, ",\"status\":"); try agentd.protocol.appendJsonString(allocator, &out, if (call.status == 0) "ok" else "provider_error"); try out.appendSlice(allocator, ",\"raw_response\":"); - try agentd.protocol.appendJsonString(allocator, &out, call.stdout); + try agentd.protocol.appendJsonString(allocator, &out, response_redacted.text); try out.appendSlice(allocator, ",\"stderr\":"); - try agentd.protocol.appendJsonString(allocator, &out, call.stderr); + try agentd.protocol.appendJsonString(allocator, &out, stderr_redacted.text); + try out.appendSlice(allocator, ",\"redacted\":"); + try out.appendSlice(allocator, if (response_redacted.changed or stderr_redacted.changed) "true" else "false"); try out.append(allocator, '}'); return out.toOwnedSlice(allocator); } diff --git a/apps/agentd/src/tools.zig b/apps/agentd/src/tools.zig index 0dfeae5..7f39afb 100644 --- a/apps/agentd/src/tools.zig +++ b/apps/agentd/src/tools.zig @@ -574,11 +574,11 @@ fn commandJsonAlloc( return out.toOwnedSlice(allocator); } -const RedactedText = struct { +pub const RedactedText = struct { text: []u8, changed: bool, - fn deinit(self: RedactedText, allocator: Allocator) void { + pub fn deinit(self: RedactedText, allocator: Allocator) void { allocator.free(self.text); } }; @@ -593,7 +593,7 @@ fn clippedView(text: []const u8, limit: usize) Clip { return .{ .text = text[0..limit], .truncated = true }; } -fn redactSecretsAlloc(allocator: Allocator, text: []const u8) !RedactedText { +pub fn redactSecretsAlloc(allocator: Allocator, text: []const u8) !RedactedText { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); var changed = false; diff --git a/apps/desktop/src/integration.zig b/apps/desktop/src/integration.zig index 0b1b6f0..1bd7294 100644 --- a/apps/desktop/src/integration.zig +++ b/apps/desktop/src/integration.zig @@ -178,10 +178,22 @@ pub fn jsonStringValue(payload: []const u8, key: []const u8) ?[]const u8 { const value_start = index + 1; var value_end = value_start; + var escaped = false; while (value_end < payload.len) : (value_end += 1) { - if (payload[value_end] == '"' and (value_end == value_start or payload[value_end - 1] != '\\')) { - return payload[value_start..value_end]; + const byte = payload[value_end]; + if (escaped) { + // Previous byte was a backslash; this byte is escaped. This + // correctly handles `\\` (the second backslash clears the + // flag) so a value ending in a backslash — e.g. a Windows + // path like "C:\\dev\\" — is not mistaken for an open string. + escaped = false; + continue; } + if (byte == '\\') { + escaped = true; + continue; + } + if (byte == '"') return payload[value_start..value_end]; } return null; } diff --git a/apps/shell/src/main.zig b/apps/shell/src/main.zig index 1a9b9bd..1999323 100644 --- a/apps/shell/src/main.zig +++ b/apps/shell/src/main.zig @@ -1807,11 +1807,6 @@ const Shell = struct { return true; } - if (parsed.hasRedirection()) { - try self.runViaSystemShell(active_line); - return true; - } - try self.runViaSystemShell(active_line); return true; } @@ -4159,6 +4154,20 @@ const Shell = struct { }; if (segments.items.len < 2) return false; + // Pre-validate every stage before running ANY of them. If a later + // stage has a redirection (or is empty) we must fall back to the + // system shell — but discovering that mid-run, after earlier stages + // already spawned, then re-running the whole line under /bin/sh would + // double-execute the non-idempotent earlier stages (e.g. the `rm f` + // in `rm f | grep x > out`). Command substitution can't reach here + // (isComplexPipelineSyntax rejects `$(`/backticks) and variable + // expansion is side-effect-free, so this extra parse is pure. + for (segments.items) |segment| { + var probe = try parseCommandExpanded(self.allocator, segment, self); + defer probe.deinit(); + if (probe.argv.items.len == 0 or probe.hasRedirection()) return false; + } + var input = try self.allocator.dupe(u8, ""); defer self.allocator.free(input); var stderr_output: std.ArrayList(u8) = .empty; @@ -4982,6 +4991,36 @@ fn childHasExited(child: *std.process.Child, io: std.Io) bool { } } + // macOS and the BSDs: reap via libc waitpid(WNOHANG). Without this branch + // these targets fell through to `return false`, so background jobs were + // never marked done — finished children lingered as zombies and `jobs` + // showed them Running forever. We handle ECHILD ourselves rather than + // calling std.posix.waitpid (which panics on it) to keep the no-panic + // guarantee. libc is linked on macOS implicitly; BSD needs link_libc. + if (builtin.os.tag == .macos or builtin.os.tag == .freebsd or + builtin.os.tag == .netbsd or builtin.os.tag == .openbsd or + builtin.os.tag == .dragonfly) + { + const pid = child.id.?; + var status: c_int = 0; + while (true) { + const result = std.c.waitpid(pid, &status, @as(c_int, @intCast(std.posix.W.NOHANG))); + switch (std.posix.errno(result)) { + .SUCCESS => { + if (result == 0) return false; + child.id = null; + return true; + }, + .INTR => continue, + .CHILD => { + child.id = null; + return true; + }, + else => return false, + } + } + } + return false; } From fda8f8af823a8814acd15e5d870dcbde2db0053c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 15:50:23 +0000 Subject: [PATCH 3/7] agentd security hardening + trim platform support to Win/Linux/macOS agentd (security): - Stop leaking the provider API key through curl argv (world-readable via ps / /proc//cmdline). Write the Authorization header to a private 0600 temp file and pass it via 'curl -H @file', then delete it. Applied to both the request path and the health probe. - Return a structured read_error envelope instead of tearing down the JSON-lines session when an input line exceeds the read buffer. platform support: - Trim FreeBSD/NetBSD/OpenBSD from the support gates (isPosixPtyTarget, currentBackend, posix_pty.supported, the posix_app import guards) and their tests. They never linked (no libc was linked for them) and weren't tested. Inert BSD switch arms remain but are no longer advertised. The macOS job-reaping branch added earlier is now macOS-only. docs: - CHANGELOG: document this polish pass (Fixed / Security / Changed). - docs/index.html: sync LOC figure to ~18.8k. Note: this environment has no Zig toolchain, so these changes are verified by static review against the surrounding code's idioms; CI on push is the first compile/test gate. https://claude.ai/code/session_01Sx5Tzwp8fTJR1chro59dhF --- CHANGELOG.md | 16 ++++++++ apps/agentd/src/main.zig | 28 +++++++++++--- apps/agentd/src/provider.zig | 68 ++++++++++++++++++++++++++++++++-- apps/desktop/src/lib.zig | 2 +- apps/desktop/src/main.zig | 2 +- apps/desktop/src/posix_pty.zig | 5 ++- apps/desktop/src/pty.zig | 15 +++++--- apps/launcher/src/main.zig | 2 +- apps/shell/src/main.zig | 17 ++++----- docs/index.html | 2 +- 10 files changed, 127 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 585a992..a6fe78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to ZiggyZag. The format is loosely [Keep a Changelog](https: ## [Unreleased] — Wave 2 +### Fixed +- **CI never ran.** `.github/workflows/ci.yml` triggered on `master`, but the default branch is `main`, so no push or PR ever exercised build/test/smoke. Pointed it at `main`. +- **macOS/BSD background jobs were never reaped.** `childHasExited` only implemented the Windows and Linux paths and fell through to `return false` elsewhere, so on macOS finished children lingered as zombies and `jobs` showed them Running forever. Added a libc `waitpid(WNOHANG)` branch that handles `ECHILD` without panicking. +- **Pipeline stages could double-execute.** `tryRunNativePipeline` detected an unsupported (redirected/empty) later stage only after earlier stages had already spawned, then fell back to the system shell and re-ran the whole line — running a non-idempotent first stage (e.g. `rm f | grep x > out`) twice. All stages are now validated before any run. +- **Desktop JSON value parser** mis-read a string value ending in a backslash (e.g. a Windows path `"C:\\dev\\"`) as unterminated. `jsonStringValue` now tracks escape state. +- `.gitattributes` now pins `*.sh`/`*.zig` to LF and `*.ps1`/`*.bat` to CRLF so a Windows checkout can't commit CRLF shebangs that break POSIX CI. + +### Security +- **AgentD no longer passes the provider API key as a curl argv element.** The `Authorization: Bearer …` header was visible to any local user via `ps` / `/proc//cmdline`. It is now written to a private `0600` temp file and passed via `curl -H @file`, then deleted. +- **AgentD provider responses are now redacted.** `raw_response`/`stderr` from the provider were emitted verbatim; they now pass through `redactSecretsAlloc` like the file/search/git tool outputs, matching the documented "redacted output" posture, and the envelope carries a `redacted` flag. +- An oversized JSON-lines input no longer tears down the AgentD session — it returns a structured `read_error` envelope and stops cleanly. + +### Changed +- Platform support trimmed to the targets that actually build, link, and are tested: **Windows, Linux, macOS**. The FreeBSD/NetBSD/OpenBSD entries (which never linked — no libc was linked for them) were dropped from the support gates; the inert BSD code paths remain but are no longer advertised. +- README metrics corrected against the tree (Zig lines 18,395 → 18,850; unit tests 198 → 213). + ### Added - **Unified theme protocol v1.** A single `ZIGGYZAG_THEME` environment variable now drives both the desktop terminal palette and the shell prompt accent. The desktop sets it when spawning the shell child; the shell reads it at startup. See [`docs/reference/theme-protocol.md`](docs/reference/theme-protocol.md). - `theme` shell builtin: `theme`, `theme list`, `theme `, `theme --json`. Switches this session's active theme and reports the current one. diff --git a/apps/agentd/src/main.zig b/apps/agentd/src/main.zig index aeb1c8f..e1f7e02 100644 --- a/apps/agentd/src/main.zig +++ b/apps/agentd/src/main.zig @@ -105,7 +105,18 @@ fn runStdio( var stdin_buffer: [4096]u8 = undefined; var stdin = std.Io.File.stdin().readerStreaming(io, &stdin_buffer); while (true) { - const line = (try stdin.interface.takeDelimiter('\n')) orelse break; + // A line longer than stdin_buffer (or an underlying read failure) would + // otherwise propagate out and tear down the whole JSON-lines session on + // a single bad input. Emit a structured error and stop cleanly — with a + // fixed buffer we can't safely resume mid-line, and continuing would + // re-hit the full buffer in a tight loop. + const maybe_line = stdin.interface.takeDelimiter('\n') catch |err| { + const json = agentd.protocol.writeErrorEnvelope(allocator, "", "read_error", @errorName(err)) catch break; + writer.writeAll(json) catch {}; + allocator.free(json); + break; + }; + const line = maybe_line orelse break; const request_line = std.mem.trim(u8, line, " \t\r"); if (request_line.len == 0) continue; var request = agentd.protocol.parseRequestAlloc(allocator, request_line) catch |err| { @@ -318,8 +329,14 @@ fn callProviderCurl( ) !ProviderCall { var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(allocator); - var auth_header: ?[]u8 = null; - defer if (auth_header) |header| allocator.free(header); + // The bearer token goes through a private 0600 temp file passed as + // `-H @path`, never as an argv element — see writeAuthHeaderFile. Both the + // file and the `@path` argument live until this function returns, by which + // point curl has already run to completion. + var auth_file: ?agentd.provider.AuthHeaderFile = null; + defer if (auth_file) |f| f.deinit(allocator, io); + var auth_arg: ?[]u8 = null; + defer if (auth_arg) |a| allocator.free(a); try argv.appendSlice(allocator, &.{ "curl", @@ -333,8 +350,9 @@ fn callProviderCurl( }); if (config.kind == .openai_compatible) { const api_key = config.api_key orelse return error.MissingApiKey; - auth_header = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{api_key}); - try argv.appendSlice(allocator, &.{ "-H", auth_header.? }); + auth_file = try agentd.provider.writeAuthHeaderFile(allocator, io, env, api_key); + auth_arg = try std.fmt.allocPrint(allocator, "@{s}", .{auth_file.?.path}); + try argv.appendSlice(allocator, &.{ "-H", auth_arg.? }); } try argv.appendSlice(allocator, &.{ "-d", "@-", endpoint }); diff --git a/apps/agentd/src/provider.zig b/apps/agentd/src/provider.zig index a479d1e..a647a41 100644 --- a/apps/agentd/src/provider.zig +++ b/apps/agentd/src/provider.zig @@ -196,8 +196,10 @@ fn probeProviderHealth( ) bool { var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(allocator); - var auth_header: ?[]u8 = null; - defer if (auth_header) |header| allocator.free(header); + var auth_file: ?AuthHeaderFile = null; + defer if (auth_file) |f| f.deinit(allocator, io); + var auth_arg: ?[]u8 = null; + defer if (auth_arg) |a| allocator.free(a); argv.appendSlice(allocator, &.{ "curl", @@ -210,8 +212,9 @@ fn probeProviderHealth( if (config.kind == .openai_compatible) { const api_key = config.api_key orelse return false; - auth_header = std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{api_key}) catch return false; - argv.appendSlice(allocator, &.{ "-H", auth_header.? }) catch return false; + auth_file = writeAuthHeaderFile(allocator, io, env, api_key) catch return false; + auth_arg = std.fmt.allocPrint(allocator, "@{s}", .{auth_file.?.path}) catch return false; + argv.appendSlice(allocator, &.{ "-H", auth_arg.? }) catch return false; } argv.append(allocator, health_endpoint) catch return false; @@ -233,6 +236,63 @@ fn nullDevicePath() []const u8 { return if (builtin.os.tag == .windows) "NUL" else "/dev/null"; } +/// A short-lived, private file holding the `Authorization` header. curl reads +/// it via `-H @path` (curl >= 7.55) so the bearer token never appears as a +/// curl argv element — argv is world-readable through `ps` and +/// `/proc//cmdline` on multi-user POSIX hosts, which would otherwise leak +/// the most sensitive secret the daemon holds for the lifetime of the request. +pub const AuthHeaderFile = struct { + path: []u8, + + pub fn deinit(self: AuthHeaderFile, allocator: Allocator, io: std.Io) void { + std.Io.Dir.cwd().deleteFile(io, self.path) catch {}; + allocator.free(self.path); + } +}; + +fn authTempRoot(env: *const std.process.Environ.Map) []const u8 { + if (env.get("TMPDIR")) |p| return p; + if (env.get("TEMP")) |p| return p; + if (env.get("TMP")) |p| return p; + return if (builtin.os.tag == .windows) "." else "/tmp"; +} + +pub fn writeAuthHeaderFile( + allocator: Allocator, + io: std.Io, + env: *std.process.Environ.Map, + api_key: []const u8, +) !AuthHeaderFile { + const root = authTempRoot(env); + var attempts: usize = 0; + while (attempts < 16) : (attempts += 1) { + var random_bytes: [12]u8 = undefined; + io.random(&random_bytes); + const hex = std.fmt.bytesToHex(random_bytes, .lower); + const name = try std.fmt.allocPrint(allocator, "ziggyzag-auth-{s}", .{hex[0..]}); + defer allocator.free(name); + + const path = try std.fs.path.join(allocator, &.{ root, name }); + errdefer allocator.free(path); + + var file = std.Io.Dir.cwd().createFile(io, path, .{ .exclusive = true, .mode = 0o600 }) catch |err| switch (err) { + error.PathAlreadyExists => { + allocator.free(path); + continue; + }, + else => |e| return e, + }; + errdefer std.Io.Dir.cwd().deleteFile(io, path) catch {}; + defer file.close(io); + + const header = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}\n", .{api_key}); + defer allocator.free(header); + try file.writeStreamingAll(io, header); + return .{ .path = path }; + } + return error.TempFileCreateFailed; +} + fn trimTrailingSlash(value: []const u8) []const u8 { var end = value.len; while (end > 0 and value[end - 1] == '/') : (end -= 1) {} diff --git a/apps/desktop/src/lib.zig b/apps/desktop/src/lib.zig index b71f463..b506c12 100644 --- a/apps/desktop/src/lib.zig +++ b/apps/desktop/src/lib.zig @@ -5,7 +5,7 @@ pub const config = @import("config.zig"); pub const pty = @import("pty.zig"); pub const terminal = @import("terminal.zig"); pub const theme = @import("theme.zig"); -pub const posix_app = if (builtin.os.tag == .linux or builtin.os.tag == .macos or builtin.os.tag == .freebsd or builtin.os.tag == .netbsd or builtin.os.tag == .openbsd) @import("posix_app.zig") else struct {}; +pub const posix_app = if (builtin.os.tag == .linux or builtin.os.tag == .macos) @import("posix_app.zig") else struct {}; pub const windows_app = if (builtin.os.tag == .windows) @import("windows_app.zig") else struct {}; test { diff --git a/apps/desktop/src/main.zig b/apps/desktop/src/main.zig index 215f845..7fa603a 100644 --- a/apps/desktop/src/main.zig +++ b/apps/desktop/src/main.zig @@ -7,7 +7,7 @@ pub fn main(init_data: std.process.Init) !void { try desktop.windows_app.run(init_data); return; } - if (builtin.os.tag == .linux or builtin.os.tag == .macos or builtin.os.tag == .freebsd or builtin.os.tag == .netbsd or builtin.os.tag == .openbsd) { + if (builtin.os.tag == .linux or builtin.os.tag == .macos) { try desktop.posix_app.run(init_data); return; } diff --git a/apps/desktop/src/posix_pty.zig b/apps/desktop/src/posix_pty.zig index bec1d5b..9ee4230 100644 --- a/apps/desktop/src/posix_pty.zig +++ b/apps/desktop/src/posix_pty.zig @@ -5,8 +5,11 @@ const posix = std.posix; const linux = std.os.linux; const native_os = builtin.os.tag; +// Linux and macOS are the supported POSIX targets. The BSD-specific code +// paths below are retained but inert: the BSDs are not built, linked, or +// tested, so `supported` excludes them. pub const supported = switch (native_os) { - .linux, .macos, .freebsd, .netbsd, .openbsd => true, + .linux, .macos => true, else => false, }; diff --git a/apps/desktop/src/pty.zig b/apps/desktop/src/pty.zig index 7dd6741..1abf3d2 100644 --- a/apps/desktop/src/pty.zig +++ b/apps/desktop/src/pty.zig @@ -19,8 +19,11 @@ pub const Backend = enum { }; pub fn isPosixPtyTarget(os_tag: std.Target.Os.Tag) bool { + // Linux and macOS are the supported POSIX targets. The BSDs share the + // libc PTY code path but are not built, linked, or tested, so they are + // not advertised as supported. return switch (os_tag) { - .linux, .macos, .freebsd, .netbsd, .openbsd => true, + .linux, .macos => true, else => false, }; } @@ -31,7 +34,7 @@ pub fn isPosixPtyTarget(os_tag: std.Target.Os.Tag) bool { pub fn currentBackend() Backend { return switch (builtin.os.tag) { .windows => .conpty_windows, - .linux, .macos, .freebsd, .netbsd, .openbsd => .script_posix, + .linux, .macos => .script_posix, else => .unavailable, }; } @@ -673,9 +676,9 @@ test "preferred backend is named" { test "posix target helper matches preferred backend coverage" { try std.testing.expect(isPosixPtyTarget(.linux)); try std.testing.expect(isPosixPtyTarget(.macos)); - try std.testing.expect(isPosixPtyTarget(.freebsd)); - try std.testing.expect(isPosixPtyTarget(.netbsd)); - try std.testing.expect(isPosixPtyTarget(.openbsd)); + try std.testing.expect(!isPosixPtyTarget(.freebsd)); + try std.testing.expect(!isPosixPtyTarget(.netbsd)); + try std.testing.expect(!isPosixPtyTarget(.openbsd)); try std.testing.expect(!isPosixPtyTarget(.windows)); } @@ -683,7 +686,7 @@ test "currentBackend resolves per OS tag" { const backend = currentBackend(); switch (builtin.os.tag) { .windows => try std.testing.expectEqual(Backend.conpty_windows, backend), - .linux, .macos, .freebsd, .netbsd, .openbsd => { + .linux, .macos => { // Today the POSIX host ships through `script(1)`; once the native // PTY backend lands this branch will tighten to `.native_posix`. try std.testing.expect(backend == .script_posix or backend == .native_posix); diff --git a/apps/launcher/src/main.zig b/apps/launcher/src/main.zig index 096be60..b70afd2 100644 --- a/apps/launcher/src/main.zig +++ b/apps/launcher/src/main.zig @@ -7,7 +7,7 @@ pub fn main(init_data: std.process.Init) !void { try desktop.windows_app.run(init_data); return; } - if (builtin.os.tag == .linux or builtin.os.tag == .macos or builtin.os.tag == .freebsd or builtin.os.tag == .netbsd or builtin.os.tag == .openbsd) { + if (builtin.os.tag == .linux or builtin.os.tag == .macos) { try desktop.posix_app.run(init_data); return; } diff --git a/apps/shell/src/main.zig b/apps/shell/src/main.zig index 1999323..1b1ae1f 100644 --- a/apps/shell/src/main.zig +++ b/apps/shell/src/main.zig @@ -4991,16 +4991,13 @@ fn childHasExited(child: *std.process.Child, io: std.Io) bool { } } - // macOS and the BSDs: reap via libc waitpid(WNOHANG). Without this branch - // these targets fell through to `return false`, so background jobs were - // never marked done — finished children lingered as zombies and `jobs` - // showed them Running forever. We handle ECHILD ourselves rather than - // calling std.posix.waitpid (which panics on it) to keep the no-panic - // guarantee. libc is linked on macOS implicitly; BSD needs link_libc. - if (builtin.os.tag == .macos or builtin.os.tag == .freebsd or - builtin.os.tag == .netbsd or builtin.os.tag == .openbsd or - builtin.os.tag == .dragonfly) - { + // macOS: reap via libc waitpid(WNOHANG). Without this branch macOS fell + // through to `return false`, so background jobs were never marked done — + // finished children lingered as zombies and `jobs` showed them Running + // forever. We handle ECHILD ourselves rather than calling + // std.posix.waitpid (which panics on it) to keep the no-panic guarantee. + // libSystem (libc) is always linked on macOS, so std.c is available. + if (builtin.os.tag == .macos) { const pid = child.id.?; var status: c_int = 0; while (true) { diff --git a/docs/index.html b/docs/index.html index 6fc5d27..62e62d6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -910,7 +910,7 @@

Where ZiggyZag fits

TabsWave 5YesYesYes Account requiredNeverNeverNeverFor some features LicenseMITMITMITClosed-source - Lines of code~18.4k~150k~210kNot public + Lines of code~18.8k~150k~210kNot public

From 854e4a2702cc66904d88aa4df1e093c7f73d6f64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 18:05:00 +0000 Subject: [PATCH 4/7] polish wave 2: valid-JSON envelopes, bounded history reads, config validation agentd: - appendJsonString now validates UTF-8 and substitutes U+FFFD for malformed bytes. Previously bytes >= 0x80 were emitted verbatim, so a tool reading a binary or non-UTF-8 file produced a JSON envelope strict parsers reject. Well-formed UTF-8 still passes through unchanged. Adds a regression test. shell: - Bound the existing-file reads in writeHistoryFile and the >> redirect append path to max_history_file_bytes, matching every other read path (they used .unlimited and could exhaust memory on a huge pre-existing file). desktop: - Wire the previously-dead migrate()/validate() into loadDesktopConfig so an out-of-range setting is rejected (or upgraded) instead of loading silently. Static review only (no Zig toolchain in this environment); CI on push is the first compile/test gate. https://claude.ai/code/session_01Sx5Tzwp8fTJR1chro59dhF --- CHANGELOG.md | 3 ++ apps/agentd/src/protocol.zig | 79 ++++++++++++++++++++++++-------- apps/desktop/src/windows_app.zig | 17 +++++-- apps/shell/src/main.zig | 4 +- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6fe78d..ab138bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to ZiggyZag. The format is loosely [Keep a Changelog](https: ## [Unreleased] — Wave 2 ### Fixed +- **AgentD could emit invalid JSON.** `appendJsonString` passed bytes ≥ 0x80 through verbatim, so a `file.read`/`rg.search`/`git.diff` over a binary or non-UTF-8 file produced an envelope that strict JSON parsers reject. It now validates each UTF-8 sequence and substitutes `U+FFFD` for malformed bytes (well-formed UTF-8 still passes through unchanged). +- **Unbounded reads when appending to history / redirect targets.** `writeHistoryFile` and the `>>` redirect append path read the existing file with no size cap while every other read path capped at `max_history_file_bytes`; both now use the same bound. +- **Desktop config validation was dead code.** `loadDesktopConfig` never called the existing `migrate()`/`validate()`, so an out-of-range value (font size, scrollback, pane count, orientation) loaded silently. They are now wired into the load path. - **CI never ran.** `.github/workflows/ci.yml` triggered on `master`, but the default branch is `main`, so no push or PR ever exercised build/test/smoke. Pointed it at `main`. - **macOS/BSD background jobs were never reaped.** `childHasExited` only implemented the Windows and Linux paths and fell through to `return false` elsewhere, so on macOS finished children lingered as zombies and `jobs` showed them Running forever. Added a libc `waitpid(WNOHANG)` branch that handles `ECHILD` without panicking. - **Pipeline stages could double-execute.** `tryRunNativePipeline` detected an unsupported (redirected/empty) later stage only after earlier stages had already spawned, then fell back to the system shell and re-ran the whole line — running a non-idempotent first stage (e.g. `rm f | grep x > out`) twice. All stages are now validated before any run. diff --git a/apps/agentd/src/protocol.zig b/apps/agentd/src/protocol.zig index 42a70cf..78745f3 100644 --- a/apps/agentd/src/protocol.zig +++ b/apps/agentd/src/protocol.zig @@ -186,25 +186,48 @@ pub fn writeErrorEnvelope( pub fn appendJsonString(allocator: Allocator, out: *std.ArrayList(u8), text: []const u8) !void { try out.append(allocator, '"'); - for (text) |byte| { - switch (byte) { - '"' => try out.appendSlice(allocator, "\\\""), - '\\' => try out.appendSlice(allocator, "\\\\"), - '\n' => try out.appendSlice(allocator, "\\n"), - '\r' => try out.appendSlice(allocator, "\\r"), - '\t' => try out.appendSlice(allocator, "\\t"), - 0x08 => try out.appendSlice(allocator, "\\b"), - 0x0c => try out.appendSlice(allocator, "\\f"), - else => { - if (byte < 0x20) { - var buf: [6]u8 = undefined; - const escaped = std.fmt.bufPrint(&buf, "\\u{x:0>4}", .{byte}) catch unreachable; - try out.appendSlice(allocator, escaped); - } else { - try out.append(allocator, byte); - } - }, + var i: usize = 0; + while (i < text.len) { + const byte = text[i]; + if (byte < 0x80) { + switch (byte) { + '"' => try out.appendSlice(allocator, "\\\""), + '\\' => try out.appendSlice(allocator, "\\\\"), + '\n' => try out.appendSlice(allocator, "\\n"), + '\r' => try out.appendSlice(allocator, "\\r"), + '\t' => try out.appendSlice(allocator, "\\t"), + 0x08 => try out.appendSlice(allocator, "\\b"), + 0x0c => try out.appendSlice(allocator, "\\f"), + else => { + if (byte < 0x20) { + var buf: [6]u8 = undefined; + const escaped = std.fmt.bufPrint(&buf, "\\u{x:0>4}", .{byte}) catch unreachable; + try out.appendSlice(allocator, escaped); + } else { + try out.append(allocator, byte); + } + }, + } + i += 1; + continue; + } + // A multibyte lead byte. Well-formed UTF-8 is legal verbatim inside a + // JSON string, but tool output (file.read / rg.search / git.diff) can + // carry binary or non-UTF-8 bytes; emitting those raw produces an + // envelope that strict JSON parsers reject. Validate each sequence and + // substitute U+FFFD for anything malformed so the envelope stays valid. + const seq_len = std.unicode.utf8ByteSequenceLength(byte) catch { + try out.appendSlice(allocator, "\u{fffd}"); + i += 1; + continue; + }; + if (i + seq_len > text.len or !std.unicode.utf8ValidateSlice(text[i .. i + seq_len])) { + try out.appendSlice(allocator, "\u{fffd}"); + i += 1; + continue; } + try out.appendSlice(allocator, text[i .. i + seq_len]); + i += seq_len; } try out.append(allocator, '"'); } @@ -372,6 +395,26 @@ test "escapes JSON strings" { try std.testing.expectEqualStrings("\"a\\\"b\\\\c\\n\"", out.items); } +test "passes valid UTF-8 through and replaces invalid bytes with U+FFFD" { + var out: std.ArrayList(u8) = .empty; + defer out.deinit(std.testing.allocator); + + // Well-formed multibyte UTF-8 (é = C3 A9) is legal verbatim in a JSON string. + try appendJsonString(std.testing.allocator, &out, "caf\u{e9}"); + try std.testing.expectEqualStrings("\"caf\u{e9}\"", out.items); + + // A lone continuation byte (0x80) is invalid UTF-8 and would otherwise make + // the whole envelope unparseable; it must collapse to U+FFFD. + out.clearRetainingCapacity(); + try appendJsonString(std.testing.allocator, &out, "a\x80b"); + try std.testing.expectEqualStrings("\"a\u{fffd}b\"", out.items); + + // A truncated sequence at end-of-input (lead byte with no continuation). + out.clearRetainingCapacity(); + try appendJsonString(std.testing.allocator, &out, "x\xc3"); + try std.testing.expectEqualStrings("\"x\u{fffd}\"", out.items); +} + test "error envelope has consistent shape with ok false and snake_case code" { const envelope = try writeErrorEnvelope(std.testing.allocator, "42", "unknown_method", "no such method"); defer std.testing.allocator.free(envelope); diff --git a/apps/desktop/src/windows_app.zig b/apps/desktop/src/windows_app.zig index cc0af6b..6a227ca 100644 --- a/apps/desktop/src/windows_app.zig +++ b/apps/desktop/src/windows_app.zig @@ -546,11 +546,22 @@ const App = struct { return; }; + // Upgrade an older/unversioned file to the current schema, then reject + // out-of-range values instead of silently loading them. migrate keeps + // every field slice borrowed from `contents`, so running it before + // `contents` is retained as config_buffer is safe. + const migrated = desktop_config.migrate(parsed); + desktop_config.validate(migrated) catch |err| { + self.setConfigError(err); + self.allocator.free(contents); + return; + }; + if (self.config_buffer) |old| self.allocator.free(old); self.config_buffer = contents; - self.config = parsed; - self.selected_theme = parsed.selected_theme; - self.status_height = if (parsed.options.show_status_bar) 28 else 0; + self.config = migrated; + self.selected_theme = migrated.selected_theme; + self.status_height = if (migrated.options.show_status_bar) 28 else 0; self.applyScrollbackLimit(); } diff --git a/apps/shell/src/main.zig b/apps/shell/src/main.zig index 1b1ae1f..379b0cf 100644 --- a/apps/shell/src/main.zig +++ b/apps/shell/src/main.zig @@ -2549,7 +2549,7 @@ const Shell = struct { defer file.close(self.io); var read_buffer: [4096]u8 = undefined; var reader = file.readerStreaming(self.io, &read_buffer); - const existing = try reader.interface.allocRemaining(self.allocator, .unlimited); + const existing = try reader.interface.allocRemaining(self.allocator, .limited(max_history_file_bytes)); defer self.allocator.free(existing); try output.appendSlice(self.allocator, existing); if (existing.len > 0 and existing[existing.len - 1] != '\n') { @@ -4125,7 +4125,7 @@ const Shell = struct { defer file.close(self.io); var read_buffer: [4096]u8 = undefined; var reader = file.readerStreaming(self.io, &read_buffer); - const existing = try reader.interface.allocRemaining(self.allocator, .unlimited); + const existing = try reader.interface.allocRemaining(self.allocator, .limited(max_history_file_bytes)); defer self.allocator.free(existing); try output.appendSlice(self.allocator, existing); } From 6ee242e9c3b701d2e111f1fb0b7d9b797735edce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 18:12:45 +0000 Subject: [PATCH 5/7] docs: align documentation with waves 1 & 2 - docs/index.html: correct stale metrics (18,395->18,850 lines, 198->213 tests; drop the unverifiable pass/skip split). - SECURITY.md: document that the provider API key is never on the curl command line (0600 temp file + -H @file) and that provider responses are redacted. - docs/reference/agentd-protocol.md: document the provider response envelope (redaction + 'redacted' flag), the read_error-on-oversized-line behavior, the UTF-8/U+FFFD JSON-string guarantee, and snake_case error codes. - apps/agentd/README.md: fix CamelCase error code in example (UnknownTool -> unknown_tool) to match the actual snake_case output. - docs/reference/data-map.md: config migrate()/validate() are now enforced on load. - docs/vision/next-20-features.md: drop BSD from the cross-platform-jobs target list (platform support is Windows/Linux/macOS). https://claude.ai/code/session_01Sx5Tzwp8fTJR1chro59dhF --- SECURITY.md | 3 ++- apps/agentd/README.md | 2 +- docs/index.html | 6 +++--- docs/reference/agentd-protocol.md | 16 +++++++++++++++- docs/reference/data-map.md | 2 +- docs/vision/next-20-features.md | 2 +- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index f34a8ad..1260d0b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,8 @@ These are the protections shipped today. We expect to add more; new layers shoul - **Path sandboxing for `file.read`**: lexical filter (no `..`, no absolute paths, no NTFS Alternate Data Stream syntax), then a non-following `lstat` walk to reject any intermediate symlink component, then a canonicalising `realpath` workspace-containment guard. Sibling-prefix escapes (`/ws-evil/...`), final-symlink escapes, and directory-symlink escapes are covered by tests. - **Bounded output**: 64 KB per file read, 96 KB per command stdout, 24 KB per stderr; 16 KB max for `terminal.write` input. - **Per-tool execution timeout** with a watchdog kill in the AgentD process tools. -- **Secret redaction** in tool output: `Authorization:` headers, Slack/GitHub/AWS-shaped tokens, PEM private-key blocks, and `KEY=value`-style secret-assignment patterns are scrubbed before the bytes leave AgentD. +- **Secret redaction** in tool output: `Authorization:` headers, Slack/GitHub/AWS-shaped tokens, PEM private-key blocks, and `KEY=value`-style secret-assignment patterns are scrubbed before the bytes leave AgentD. Provider responses (`raw_response`/`stderr`) pass through the same redaction. +- **Provider credentials never appear in process argv**: the provider API key (the `Authorization: Bearer …` header) is written to a private `0600` temp file and passed to `curl` via `-H @file`, then deleted — so it is not exposed to other local users through `ps` or `/proc//cmdline`. - **OSC consumption**: the terminal core consumes non-777 OSC strings (BEL/ST/CAN/SUB-terminated) without acting on their payloads, so a hostile remote process cannot use OSC titles or clipboard sequences to drive UI state. - **No `@panic` calls** anywhere in the source. Errors propagate; the process does not crash on user input. - **No `std.debug.assert` calls** that would be compiled out — invariants are explicit `return error.X` instead. diff --git a/apps/agentd/README.md b/apps/agentd/README.md index 6c73140..aa1755f 100644 --- a/apps/agentd/README.md +++ b/apps/agentd/README.md @@ -99,7 +99,7 @@ Responses are envelopes: ```json {"id":0,"ok":true,"result":{"provider":"ollama","model":"qwen2.5-coder:1.5b","endpoint":"http://127.0.0.1:11434/api/chat","health_endpoint":"http://127.0.0.1:11434/api/tags","stream":false,"curl":"available","api_key":"not_required","provider_status":"reachable","ready":true}} -{"id":2,"ok":false,"error":{"code":"UnknownTool","message":"unknown tool name"}} +{"id":2,"ok":false,"error":{"code":"unknown_tool","message":"unknown tool name"}} ``` `agent/health` is a readiness check, not just a config echo. It reports `curl`, `api_key`, `provider_status`, and `ready`. Expected `provider_status` values include `reachable`, `unreachable`, `missing_api_key`, and `curl_unavailable`. diff --git a/docs/index.html b/docs/index.html index 62e62d6..97d3c2b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -684,7 +684,7 @@

One theme, two surfaces

ziggy ~/dev/ZiggyZag git:main
❯ zig build test
-
196/198 tests passed (2 skipped)
+
213 unit tests (some skip on non-target platforms)
warning: bounded scrollback (10000 lines)
error: file not found (recoverable)
info: theme cycle next → catppuccin-mocha
@@ -925,8 +925,8 @@

Where ZiggyZag fits

Numbers that matter

The number that matters most is "lines of Zig you can read in a focused sitting." The rest is supporting evidence.

-
18,395
lines of Zig
-
198
unit tests (196 pass, 2 skip)
+
18,850
lines of Zig
+
213
unit tests (some skip off-target)
20
built-in themes
5
cross-built targets
4
binaries, one workspace
diff --git a/docs/reference/agentd-protocol.md b/docs/reference/agentd-protocol.md index 39a4e68..4fb3064 100644 --- a/docs/reference/agentd-protocol.md +++ b/docs/reference/agentd-protocol.md @@ -54,11 +54,17 @@ Example `tools/call`: ### Response: Error ```json -{"id":"1","ok":false,"error":{"code":"METHOD_NOT_FOUND","message":"unknown method: foo"}} +{"id":"1","ok":false,"error":{"code":"unknown_method","message":"foo"}} ``` +Error `code` values are `snake_case` (e.g. `unknown_method`, `unknown_tool`, `unsafe_path`, `read_error`). + If the request itself cannot be parsed, the `id` field is recovered via `bestEffortId` — a best-effort extraction of the raw `id` value from the line even when JSON parsing fails. This ensures the caller can correlate error responses with their requests. +If a single input line exceeds the read buffer (or stdin read fails), AgentD emits `{"id":"","ok":false,"error":{"code":"read_error","message":"..."}}` and ends the session cleanly rather than crashing. + +All JSON strings are emitted as valid UTF-8: tool output that contains binary or non-UTF-8 bytes (e.g. `file.read` over a binary file) has each malformed byte substituted with U+FFFD, so the response envelope is always parseable. Well-formed UTF-8 passes through unchanged. + ## Methods ### agent/health @@ -95,6 +101,14 @@ Run an agent prompt. AgentD first attempts to call a configured provider via cur {"id":"4","method":"agent/run","prompt":"Summarize what this project does."} ``` +When a provider is reached, the result envelope is: + +```json +{"id":"4","ok":true,"result":{"provider":"openai-compatible","model":"...","endpoint":"...","status":"ok","raw_response":"...","stderr":"...","redacted":false}} +``` + +`status` is `"ok"` when the provider call exits 0, else `"provider_error"`. Both `raw_response` and `stderr` are passed through the same secret redaction as tool output before entering the envelope, and `redacted` is `true` when redaction changed either stream. The provider API key is never placed on the `curl` command line — it is written to a private `0600` temp file and passed via `-H @file`. On fallback (provider unavailable / no API key / curl missing) the result is instead `{"provider","model","endpoint","prompt","status","next_tools"}`. + Provider configuration is via environment variables: | Variable | Purpose | diff --git a/docs/reference/data-map.md b/docs/reference/data-map.md index 21885f2..aa44d96 100644 --- a/docs/reference/data-map.md +++ b/docs/reference/data-map.md @@ -30,4 +30,4 @@ Start at [README.md](../README.md) for the documentation hub. Use [task-system.m - No committed template for friend-test reports; [daily-driver-qa.md](../guides/daily-driver-qa.md) has a report section, but generated reports are local. - AgentD audit events are described in docs and visible in runtime transcripts, but there is no documented exported audit file path yet. - Linux/macOS release runtime results still require real hosts or CI runners before cross-platform artifacts can be called fully runtime-tested. -- Config migration/versioning and support bundle paths are still planned work, so config data recovery is not yet mapped to a stable artifact. +- Config migration/versioning is now enforced on the desktop config load path (`config.zig` `migrate()`/`validate()` run at load, upgrading older schemas and rejecting out-of-range values); crash-time support-bundle paths remain planned work, so config data recovery is not yet mapped to a stable artifact. diff --git a/docs/vision/next-20-features.md b/docs/vision/next-20-features.md index c9cbab9..36a57d8 100644 --- a/docs/vision/next-20-features.md +++ b/docs/vision/next-20-features.md @@ -74,7 +74,7 @@ The remaining work is concentrated in four buckets: Primary paths: `apps/shell/src/main.zig`. - [ ] 11. Cross-platform background jobs - Harden nonblocking child status on Windows, macOS, Linux, and BSD; define which process-group semantics, terminal control, and foreground/background behaviors are intentionally absent. + Harden nonblocking child status on Windows, macOS, and Linux; define which process-group semantics, terminal control, and foreground/background behaviors are intentionally absent. Progress: practical `wait`, `kill`, `disown`, `fg`, and `bg` builtins are implemented around ZiggyZag's background job table. Full process-group job control is intentionally not claimed yet. Primary paths: `apps/shell/src/main.zig`. From f3e763a631a59a814e251b3253256cdec1f5716b Mon Sep 17 00:00:00 2001 From: PascalAI2024 Date: Sun, 31 May 2026 16:47:48 -0400 Subject: [PATCH 6/7] fix: repair Zig 0.16.0 build + test for agentd auth file and pty stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions in this PR broke CI on all platforms: 1. provider.zig: Io.Dir.CreateFileOptions has no `mode` field in Zig 0.16.0, so createFile(.., .{ .exclusive = true, .mode = 0o600 }) failed to compile. Create the auth-header file exclusively, then set 0600 explicitly via file.setPermissions(io, Permissions.fromMode(0o600)) on POSIX (guarded out on Windows, where the field is meaningless and ACL defaults already restrict access). Preserves the intended owner-only permission on the bearer-token temp file. 2. pty.zig: the "posix backend stubs return NotImplemented" test asserted spawnNativePosix returns NotImplemented, but the PR made the POSIX path a real fork/execve implementation, so on Linux/macOS it now hits the empty-argv guard (error.EmptyArgv) instead. Guard that one assertion to Windows (where the function genuinely returns NotImplemented); keep asserting the two real stubs on all platforms. Verified locally with the pinned zig 0.16.0 toolchain: zig build, zig build test (214 tests), and scripts/smoke.sh all pass. Co-Authored-By: Claude Opus 4.8 (1M context) | Model: claude-opus-4 | Mode: ultrathink | 🤖 J.A.R.V.I.S. --- apps/agentd/src/provider.zig | 10 +++++++++- apps/desktop/src/pty.zig | 8 +++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/agentd/src/provider.zig b/apps/agentd/src/provider.zig index a647a41..e3c3d83 100644 --- a/apps/agentd/src/provider.zig +++ b/apps/agentd/src/provider.zig @@ -275,7 +275,7 @@ pub fn writeAuthHeaderFile( const path = try std.fs.path.join(allocator, &.{ root, name }); errdefer allocator.free(path); - var file = std.Io.Dir.cwd().createFile(io, path, .{ .exclusive = true, .mode = 0o600 }) catch |err| switch (err) { + var file = std.Io.Dir.cwd().createFile(io, path, .{ .exclusive = true }) catch |err| switch (err) { error.PathAlreadyExists => { allocator.free(path); continue; @@ -285,6 +285,14 @@ pub fn writeAuthHeaderFile( errdefer std.Io.Dir.cwd().deleteFile(io, path) catch {}; defer file.close(io); + // Restrict the auth-header file to owner read/write (0600) on POSIX so + // the bearer token is not world-readable. `CreateFileOptions` has no + // `mode` field, so permissions are set explicitly after creation. + // Windows ACL defaults already deny other users; `mode` is meaningless there. + if (builtin.os.tag != .windows) { + try file.setPermissions(io, std.Io.File.Permissions.fromMode(0o600)); + } + const header = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}\n", .{api_key}); defer allocator.free(header); try file.writeStreamingAll(io, header); diff --git a/apps/desktop/src/pty.zig b/apps/desktop/src/pty.zig index 1abf3d2..f7284ec 100644 --- a/apps/desktop/src/pty.zig +++ b/apps/desktop/src/pty.zig @@ -715,7 +715,13 @@ test "posix backend stubs return NotImplemented" { .rows = 24, }; - try std.testing.expectError(error.NotImplemented, spawnNativePosix(opts)); + // `spawnNativePosix` only returns NotImplemented on Windows; on POSIX it + // delegates to the real fork/execve implementation, whose runtime behaviour + // cannot be validated in CI. `spawnScriptPosix`/`spawnDirectPosix` remain + // genuine stubs that return NotImplemented on every platform. + if (builtin.os.tag == .windows) { + try std.testing.expectError(error.NotImplemented, spawnNativePosix(opts)); + } try std.testing.expectError(error.NotImplemented, spawnScriptPosix(opts)); try std.testing.expectError(error.NotImplemented, spawnDirectPosix(opts)); } From c26ca3f4331803f2955cf8c308b9425fba4affed Mon Sep 17 00:00:00 2001 From: PascalAI2024 Date: Sun, 31 May 2026 18:27:07 -0400 Subject: [PATCH 7/7] fix: stream shell stdout/stderr so piped output keeps order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell created its stdout/stderr writers via `std.Io.File.stdout() .writer(self.io, ...)`, which defaults to POSITIONAL writes (pwrite at a per-writer `pos` starting at 0). Each command path constructs a fresh writer (run loop, emitCommandOutput, pipelines, jobs), so when stdout is a regular file every new writer seeks back to offset 0 and overwrites the previously written bytes — turning three echoed lines into garbage like `line t$ ee`. Pipes hid the bug (not seekable -> sequential write), which is why only the POSIX smoke step (`... > out.txt 2>&1`) failed while the interactive/TTY path looked fine. Switch all 12 shell stdout/stderr writer constructions to `writerStreaming`, which writes sequentially against the shared OS file offset. Independent writer objects — and inherited-stdout child processes spawned via /bin/sh — now all append in order. Matches the existing `readerStreaming` / `writeStreamingAll` usage style. Verified with Zig 0.16.0: `zig build`, `zig build test` (all green), and `sh ./scripts/smoke.sh` now prints "ZiggyZag smoke passed". Co-Authored-By: Claude Opus 4.8 (1M context) | Model: claude-opus-4 | Mode: ultrathink | 🤖 J.A.R.V.I.S. --- apps/shell/src/main.zig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/shell/src/main.zig b/apps/shell/src/main.zig index 379b0cf..9d621d3 100644 --- a/apps/shell/src/main.zig +++ b/apps/shell/src/main.zig @@ -244,11 +244,11 @@ const Shell = struct { fn run(self: *Shell) !void { var stdin_buffer: [4096]u8 = undefined; var stdin = std.Io.File.stdin().readerStreaming(self.io, &stdin_buffer); - var stdout = std.Io.File.stdout().writer(self.io, &.{}); + var stdout = std.Io.File.stdout().writerStreaming(self.io, &.{}); self.loadStartupConfig() catch |err| switch (err) { error.ConfigFileTooLarge => { - var stderr = std.Io.File.stderr().writer(self.io, &.{}); + var stderr = std.Io.File.stderr().writerStreaming(self.io, &.{}); try stderr.interface.print("config: file too large (limit {d} bytes); skipping startup config\n", .{max_config_file_bytes}); }, else => |e| return e, @@ -266,7 +266,7 @@ const Shell = struct { self.readHistoryFile(path) catch |err| switch (err) { error.FileNotFound => {}, error.HistoryFileTooLarge => { - var stderr = std.Io.File.stderr().writer(self.io, &.{}); + var stderr = std.Io.File.stderr().writerStreaming(self.io, &.{}); try stderr.interface.print("history: {s}: file too large (limit {d} bytes)\n", .{ path, max_history_file_bytes }); }, else => |e| return e, @@ -280,7 +280,7 @@ const Shell = struct { self.readHistoryMetaFile(path) catch |err| switch (err) { error.FileNotFound => {}, error.HistoryFileTooLarge => { - var stderr = std.Io.File.stderr().writer(self.io, &.{}); + var stderr = std.Io.File.stderr().writerStreaming(self.io, &.{}); try stderr.interface.print("history-meta: {s}: file too large (limit {d} bytes)\n", .{ path, max_history_file_bytes }); }, else => |e| return e, @@ -1872,14 +1872,14 @@ const Shell = struct { defer stdout_buffer.deinit(self.allocator); const rest = std.mem.trim(u8, line[end..], " \t"); try self.inspectLine(rest, &stdout_buffer); - var stdout = std.Io.File.stdout().writer(self.io, &.{}); + var stdout = std.Io.File.stdout().writerStreaming(self.io, &.{}); try stdout.interface.writeAll(stdout_buffer.items); self.last_status = 0; return true; } fn printParseError(self: *Shell, line: []const u8, err: anyerror) !void { - var stderr = std.Io.File.stderr().writer(self.io, &.{}); + var stderr = std.Io.File.stderr().writerStreaming(self.io, &.{}); const message = switch (err) { error.UnterminatedSingleQuote => "unterminated single quote", error.UnterminatedDoubleQuote => "unterminated double quote", @@ -3500,7 +3500,7 @@ const Shell = struct { }); stored = true; - var stdout = std.Io.File.stdout().writer(self.io, &.{}); + var stdout = std.Io.File.stdout().writerStreaming(self.io, &.{}); try stdout.interface.print("[{d}] {d}\n", .{ job_number, childIdForDisplay(&child) }); } @@ -3802,7 +3802,7 @@ const Shell = struct { } if (output.items.len > 0) { - var stdout = std.Io.File.stdout().writer(self.io, &.{}); + var stdout = std.Io.File.stdout().writerStreaming(self.io, &.{}); try stdout.interface.writeAll(output.items); } @@ -4089,14 +4089,14 @@ const Shell = struct { if (parsed.stdout_redirect) |redirect| { try self.writeRedirect(redirect, stdout_bytes); } else if (stdout_bytes.len > 0) { - var stdout = std.Io.File.stdout().writer(self.io, &.{}); + var stdout = std.Io.File.stdout().writerStreaming(self.io, &.{}); try stdout.interface.writeAll(stdout_bytes); } if (parsed.stderr_redirect) |redirect| { try self.writeRedirect(redirect, stderr_bytes); } else if (stderr_bytes.len > 0) { - var stderr = std.Io.File.stderr().writer(self.io, &.{}); + var stderr = std.Io.File.stderr().writerStreaming(self.io, &.{}); try stderr.interface.writeAll(stderr_bytes); } } @@ -4207,9 +4207,9 @@ const Shell = struct { status = result.status; } - var stdout = std.Io.File.stdout().writer(self.io, &.{}); + var stdout = std.Io.File.stdout().writerStreaming(self.io, &.{}); try stdout.interface.writeAll(input); - var stderr = std.Io.File.stderr().writer(self.io, &.{}); + var stderr = std.Io.File.stderr().writerStreaming(self.io, &.{}); try stderr.interface.writeAll(stderr_output.items); self.last_status = status; return true;