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/CHANGELOG.md b/CHANGELOG.md index 585a992..ab138bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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. +- **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/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/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/apps/agentd/src/main.zig b/apps/agentd/src/main.zig index 68ce1c8..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| { @@ -237,6 +248,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 +268,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); } @@ -307,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", @@ -322,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/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/agentd/src/provider.zig b/apps/agentd/src/provider.zig index a479d1e..e3c3d83 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,71 @@ 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 }) 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); + + // 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); + 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/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/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..f7284ec 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); @@ -712,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)); } 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/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 1a9b9bd..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, @@ -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; } @@ -1877,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", @@ -2554,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') { @@ -3505,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) }); } @@ -3807,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); } @@ -4094,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); } } @@ -4130,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); } @@ -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; @@ -4198,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; @@ -4982,6 +4991,33 @@ fn childHasExited(child: *std.process.Child, io: std.Io) bool { } } + // 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) { + 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; } diff --git a/docs/index.html b/docs/index.html index 6fc5d27..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
@@ -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

@@ -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`. 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"