From cb63786b1cf611af6e73fa40a30e81f16351232e Mon Sep 17 00:00:00 2001 From: Yeon Vinzenz Varapragasam Date: Thu, 7 May 2026 16:12:07 +0200 Subject: [PATCH 1/2] fix(config): ignore unknown fields when parsing config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config parsing currently fails with error.UnknownField if config.json contains any key not in the Config struct. This causes a hard crash on startup whenever a newer tool (e.g. nullhub's integration API) has written additional fields — such as the 'tracker' key — that an older binary does not yet recognise. Fix: pass ignore_unknown_fields = true, consistent with the existing behaviour in ConcurrencyConfig parsing (line 316). This allows forward-compatible config files and prevents crashes on upgrade/downgrade paths between component versions. --- src/config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.zig b/src/config.zig index 26826dd..c7df78d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -122,7 +122,7 @@ pub fn loadFromFile(allocator: std.mem.Allocator, path: []const u8) !Config { // Do NOT free `contents` or deinit `parsed` here. // `Config` fields may point into these allocations. // The caller should provide an arena allocator and clean it up once on shutdown. - const parsed = try std.json.parseFromSlice(Config, allocator, contents, .{}); + const parsed = try std.json.parseFromSlice(Config, allocator, contents, .{ .ignore_unknown_fields = true }); return parsed.value; } From faf0f8dccca785c19b71c43372eadc94bd53b55a Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Wed, 13 May 2026 09:05:23 -0300 Subject: [PATCH 2/2] fix(config): tolerate unknown fields in file configs --- src/config.zig | 66 +++++++++++++++++++++++++++++++++++++ src/strategy.zig | 29 ++++++++++++++++- src/workflow_loader.zig | 72 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/config.zig b/src/config.zig index c7df78d..413f563 100644 --- a/src/config.zig +++ b/src/config.zig @@ -230,6 +230,72 @@ test "loadFromFile reads configured host and worker URL from JSON file" { try std.testing.expectEqualStrings("workflows", cfg.tracker.?.workflows_dir); } +test "loadFromFile ignores unknown fields at all config levels" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const cfg_json = + \\{ + \\ "host": "0.0.0.0", + \\ "future_top_level": true, + \\ "workers": [ + \\ { + \\ "id": "w1", + \\ "url": "http://localhost:3000/webhook", + \\ "token": "tok", + \\ "future_worker": "ignored" + \\ } + \\ ], + \\ "engine": { + \\ "poll_interval_ms": 250, + \\ "future_engine": 42 + \\ }, + \\ "tracker": { + \\ "url": "http://127.0.0.1:7700", + \\ "future_tracker": true, + \\ "concurrency": { + \\ "max_concurrent_tasks": 2, + \\ "future_concurrency": 9 + \\ }, + \\ "workspace": { + \\ "root": "workspaces", + \\ "future_workspace": "ignored", + \\ "hooks": { + \\ "before_run": "echo ok", + \\ "future_hook": "ignored" + \\ } + \\ }, + \\ "subprocess": { + \\ "command": "nullclaw", + \\ "future_subprocess": "ignored" + \\ } + \\ } + \\} + ; + + try std_compat.fs.Dir.wrap(tmp.dir).writeFile(.{ + .sub_path = "config.json", + .data = cfg_json, + }); + + const cfg_path = try std_compat.fs.Dir.wrap(tmp.dir).realpathAlloc(std.testing.allocator, "config.json"); + defer std.testing.allocator.free(cfg_path); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const cfg = try loadFromFile(arena.allocator(), cfg_path); + try std.testing.expectEqualStrings("0.0.0.0", cfg.host); + try std.testing.expectEqual(@as(usize, 1), cfg.workers.len); + try std.testing.expectEqualStrings("w1", cfg.workers[0].id); + try std.testing.expectEqual(@as(u32, 250), cfg.engine.poll_interval_ms); + try std.testing.expectEqualStrings("http://127.0.0.1:7700", cfg.tracker.?.url.?); + try std.testing.expectEqual(@as(u32, 2), cfg.tracker.?.concurrency.max_concurrent_tasks); + try std.testing.expectEqualStrings("workspaces", cfg.tracker.?.workspace.root); + try std.testing.expectEqualStrings("echo ok", cfg.tracker.?.workspace.hooks.before_run.?); + try std.testing.expectEqualStrings("nullclaw", cfg.tracker.?.subprocess.command); +} + test "TrackerConfig defaults" { const tc = TrackerConfig{}; try std.testing.expectEqual(@as(?[]const u8, null), tc.url); diff --git a/src/strategy.zig b/src/strategy.zig index a3a9e2c..2d5ee5b 100644 --- a/src/strategy.zig +++ b/src/strategy.zig @@ -39,7 +39,7 @@ pub fn loadStrategies(allocator: std.mem.Allocator, dir_path: []const u8) Strate if (!std.mem.endsWith(u8, entry.name, ".json")) continue; const contents = dir.readFileAlloc(allocator, entry.name, 1024 * 1024) catch continue; - const parsed = std.json.parseFromSlice(Strategy, allocator, contents, .{}) catch continue; + const parsed = std.json.parseFromSlice(Strategy, allocator, contents, .{ .ignore_unknown_fields = true }) catch continue; const strategy = parsed.value; map.put(allocator, strategy.name, strategy) catch continue; @@ -272,6 +272,33 @@ test "loadStrategies: loads from directory" { try std.testing.expectEqualStrings("independent", par.build); } +test "loadStrategies: ignores unknown fields" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try std_compat.fs.Dir.wrap(tmp.dir).writeFile(.{ + .sub_path = "future.json", + .data = + \\{ + \\ "name": "future", + \\ "description": "forward compatible", + \\ "build": "chain", + \\ "future_strategy_field": true + \\} + , + }); + + const dir_path = try std_compat.fs.Dir.wrap(tmp.dir).realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(dir_path); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const map = loadStrategies(arena.allocator(), dir_path); + try std.testing.expectEqual(@as(usize, 1), map.count()); + try std.testing.expectEqualStrings("chain", map.get("future").?.build); +} + test "expandStrategy: passthrough when no strategy field" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); diff --git a/src/workflow_loader.zig b/src/workflow_loader.zig index 10ed417..94d3e3a 100644 --- a/src/workflow_loader.zig +++ b/src/workflow_loader.zig @@ -114,7 +114,7 @@ pub fn loadWorkflows(allocator: std.mem.Allocator, dir_path: []const u8) Workflo if (!std.mem.endsWith(u8, entry.name, ".json")) continue; const contents = dir.readFileAlloc(allocator, entry.name, 1024 * 1024) catch continue; - const parsed = std.json.parseFromSlice(WorkflowDef, allocator, contents, .{}) catch continue; + const parsed = std.json.parseFromSlice(WorkflowDef, allocator, contents, .{ .ignore_unknown_fields = true }) catch continue; const def = parsed.value; if (def.pipeline_id.len == 0) continue; @@ -210,7 +210,7 @@ pub fn validateWorkflowFiles(allocator: std.mem.Allocator, dir_path: []const u8) }; raw_json.deinit(); - const parsed = std.json.parseFromSlice(WorkflowDef, allocator, contents, .{}) catch { + const parsed = std.json.parseFromSlice(WorkflowDef, allocator, contents, .{ .ignore_unknown_fields = true }) catch { try appendWorkflowDiagnostic( allocator, &diagnostics, @@ -553,6 +553,42 @@ test "loadWorkflows: loads JSON files from directory" { try std.testing.expectEqualStrings("deploy", dep.dispatch.worker_tags[0]); } +test "loadWorkflows: ignores unknown fields" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try std_compat.fs.Dir.wrap(tmp.dir).writeFile(.{ + .sub_path = "forward.json", + .data = + \\{ + \\ "id": "wf-forward", + \\ "pipeline_id": "forward", + \\ "claim_roles": ["coder"], + \\ "future_workflow_field": true, + \\ "subprocess": { + \\ "command": "nullclaw", + \\ "future_subprocess_field": "ignored" + \\ }, + \\ "dispatch": { + \\ "worker_tags": ["coder"], + \\ "future_dispatch_field": "ignored" + \\ } + \\} + , + }); + + const dir_path = try std_compat.fs.Dir.wrap(tmp.dir).realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(dir_path); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const map = loadWorkflows(arena.allocator(), dir_path); + try std.testing.expectEqual(@as(usize, 1), map.count()); + try std.testing.expectEqualStrings("wf-forward", map.get("forward").?.id); + try std.testing.expectEqualStrings("nullclaw", map.get("forward").?.subprocess.command); +} + test "loadWorkflows: skips files with empty pipeline_id" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -673,6 +709,38 @@ test "validateWorkflowFiles: valid workflow directory" { try std.testing.expectEqualStrings("pipeline-valid", result.files[0].pipeline_id); } +test "validateWorkflowFiles: ignores unknown fields" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try std_compat.fs.Dir.wrap(tmp.dir).writeFile(.{ + .sub_path = "forward.json", + .data = + \\{ + \\ "id": "wf-forward", + \\ "pipeline_id": "pipeline-forward", + \\ "claim_roles": ["developer"], + \\ "future_workflow_field": true, + \\ "retry": { + \\ "max_attempts": 2, + \\ "future_retry_field": "ignored" + \\ } + \\} + , + }); + + const dir_path = try std_compat.fs.Dir.wrap(tmp.dir).realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(dir_path); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const result = try validateWorkflowFiles(arena.allocator(), dir_path); + try std.testing.expectEqual(@as(usize, 1), result.checked_files); + try std.testing.expectEqual(@as(usize, 1), result.valid_files); + try std.testing.expectEqual(@as(usize, 0), result.error_count); +} + test "validateWorkflowFiles: malformed JSON is an error" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup();