diff --git a/src/api/config.zig b/src/api/config.zig index 4df209b..bbadd28 100644 --- a/src/api/config.zig +++ b/src/api/config.zig @@ -183,6 +183,20 @@ pub const ParsedConfigPath = struct { name: []const u8, }; +pub const ParsedConfigPathOwned = struct { + component: []u8, + name: []u8, + + pub fn deinit(self: ParsedConfigPathOwned, allocator: std.mem.Allocator) void { + allocator.free(self.component); + allocator.free(self.name); + } + + pub fn borrowed(self: ParsedConfigPathOwned) ParsedConfigPath { + return .{ .component = self.component, .name = self.name }; + } +}; + pub fn parseConfigPath(target: []const u8) ?ParsedConfigPath { const prefix = "/api/instances/"; const suffix = "/config"; @@ -205,6 +219,16 @@ pub fn parseConfigPath(target: []const u8) ?ParsedConfigPath { return .{ .component = component, .name = name }; } +pub fn parseConfigPathAlloc(allocator: std.mem.Allocator, target: []const u8) !?ParsedConfigPathOwned { + const parsed = try query.parseInstancePathPrefixAlloc(allocator, target) orelse return null; + errdefer parsed.deinit(allocator); + if (!std.mem.eql(u8, parsed.suffix, "config")) { + parsed.deinit(allocator); + return null; + } + return .{ .component = parsed.component, .name = parsed.name }; +} + fn lookupJsonPath(root: std.json.Value, dot_path: []const u8) ?std.json.Value { if (dot_path.len == 0) return null; @@ -251,6 +275,14 @@ test "parseConfigPath: keeps working with query string" { try std.testing.expectEqualStrings("my-agent", p.name); } +test "parseConfigPathAlloc decodes percent-encoded names" { + const allocator = std.testing.allocator; + const p = (try parseConfigPathAlloc(allocator, "/api/instances/nullclaw/Opencode%20Go/config?path=gateway.port")).?; + defer p.deinit(allocator); + try std.testing.expectEqualStrings("nullclaw", p.component); + try std.testing.expectEqualStrings("Opencode Go", p.name); +} + test "parseConfigPath: rejects path without /config suffix" { try std.testing.expect(parseConfigPath("/api/instances/nullclaw/my-agent") == null); } diff --git a/src/api/instances.zig b/src/api/instances.zig index 86df295..6fc0759 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -948,7 +948,13 @@ pub fn prepareGatewayProxy( target: []const u8, body: []const u8, ) GatewayProxyPrepareResult { - const parsed = parsePath(target) orelse return .no_match; + const parsed_owned = parsePathAlloc(allocator, target) catch |err| switch (err) { + error.InvalidPathSegment => return .{ .response = badRequest("{\"error\":\"invalid path segment\"}") }, + else => return .{ .response = helpers.serverError() }, + }; + const parsed_storage = parsed_owned orelse return .no_match; + defer parsed_storage.deinit(allocator); + const parsed = parsed_storage.borrowed(); const action = parsed.action orelse return .no_match; const route = gatewayProxyRouteForAction(action) orelse return .no_match; if (!std.mem.eql(u8, method, "POST")) return .{ .response = methodNotAllowed() }; @@ -1252,12 +1258,52 @@ pub const ParsedPath = struct { action: ?[]const u8, }; +const ParsedPathOwned = struct { + component: []u8, + name: []u8, + action: ?[]u8, + + fn deinit(self: ParsedPathOwned, allocator: std.mem.Allocator) void { + allocator.free(self.component); + allocator.free(self.name); + if (self.action) |action| allocator.free(action); + } + + fn borrowed(self: ParsedPathOwned) ParsedPath { + return .{ + .component = self.component, + .name = self.name, + .action = self.action, + }; + } +}; + const ParsedChannelsPath = struct { component: []const u8, name: []const u8, channel_type: ?[]const u8, }; +const ParsedChannelsPathOwned = struct { + component: []u8, + name: []u8, + channel_type: ?[]u8, + + fn deinit(self: ParsedChannelsPathOwned, allocator: std.mem.Allocator) void { + allocator.free(self.component); + allocator.free(self.name); + if (self.channel_type) |channel_type| allocator.free(channel_type); + } + + fn borrowed(self: ParsedChannelsPathOwned) ParsedChannelsPath { + return .{ + .component = self.component, + .name = self.name, + .channel_type = self.channel_type, + }; + } +}; + fn stripQuery(target: []const u8) []const u8 { if (std.mem.indexOfScalar(u8, target, '?')) |qmark| { return target[0..qmark]; @@ -1293,6 +1339,26 @@ pub fn parsePath(target: []const u8) ?ParsedPath { return .{ .component = component, .name = name, .action = action }; } +fn parsePathAlloc(allocator: std.mem.Allocator, target: []const u8) !?ParsedPathOwned { + const parsed = parsePath(target) orelse return null; + + const component = try query_api.decodePathSegmentAlloc(allocator, parsed.component); + errdefer allocator.free(component); + const name = try query_api.decodePathSegmentAlloc(allocator, parsed.name); + errdefer allocator.free(name); + const action = if (parsed.action) |value| + try query_api.decodePathSegmentAlloc(allocator, value) + else + null; + errdefer if (action) |owned| allocator.free(owned); + + return .{ + .component = component, + .name = name, + .action = action, + }; +} + fn parseChannelsPath(target: []const u8) ?ParsedChannelsPath { const clean = stripQuery(target); const prefix = "/api/instances/"; @@ -1321,6 +1387,26 @@ fn parseChannelsPath(target: []const u8) ?ParsedChannelsPath { }; } +fn parseChannelsPathAlloc(allocator: std.mem.Allocator, target: []const u8) !?ParsedChannelsPathOwned { + const parsed = parseChannelsPath(target) orelse return null; + + const component = try query_api.decodePathSegmentAlloc(allocator, parsed.component); + errdefer allocator.free(component); + const name = try query_api.decodePathSegmentAlloc(allocator, parsed.name); + errdefer allocator.free(name); + const channel_type = if (parsed.channel_type) |value| + try query_api.decodePathSegmentAlloc(allocator, value) + else + null; + errdefer if (channel_type) |owned| allocator.free(owned); + + return .{ + .component = component, + .name = name, + .channel_type = channel_type, + }; +} + pub const UsageLedgerLine = struct { ts: i64 = 0, provider: ?[]const u8 = null, @@ -1754,6 +1840,28 @@ const ParsedCronPath = struct { }; }; +const ParsedCronPathOwned = struct { + component: []u8, + name: []u8, + job_id: ?[]u8 = null, + action: ParsedCronPath.Action, + + fn deinit(self: ParsedCronPathOwned, allocator: std.mem.Allocator) void { + allocator.free(self.component); + allocator.free(self.name); + if (self.job_id) |job_id| allocator.free(job_id); + } + + fn borrowed(self: ParsedCronPathOwned) ParsedCronPath { + return .{ + .component = self.component, + .name = self.name, + .job_id = self.job_id, + .action = self.action, + }; + } +}; + const LoadedCronStore = struct { parsed: std.json.Parsed(std.json.Value), @@ -1828,6 +1936,27 @@ fn parseCronPath(target: []const u8) ?ParsedCronPath { }; } +fn parseCronPathAlloc(allocator: std.mem.Allocator, target: []const u8) !?ParsedCronPathOwned { + const parsed = parseCronPath(target) orelse return null; + + const component = try query_api.decodePathSegmentAlloc(allocator, parsed.component); + errdefer allocator.free(component); + const name = try query_api.decodePathSegmentAlloc(allocator, parsed.name); + errdefer allocator.free(name); + const job_id = if (parsed.job_id) |value| + try query_api.decodePathSegmentAlloc(allocator, value) + else + null; + errdefer if (job_id) |owned| allocator.free(owned); + + return .{ + .component = component, + .name = name, + .job_id = job_id, + .action = parsed.action, + }; +} + fn loadCronStore(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8, name: []const u8) !LoadedCronStore { const inst_dir = try paths.instanceDir(allocator, component, name); defer allocator.free(inst_dir); @@ -4920,7 +5049,13 @@ pub fn dispatch( return methodNotAllowed(); } - if (parseCronPath(target)) |parsed_cron| { + const parsed_cron_owned = parseCronPathAlloc(allocator, target) catch |err| switch (err) { + error.InvalidPathSegment => return badRequest("{\"error\":\"invalid path segment\"}"), + else => return helpers.serverError(), + }; + if (parsed_cron_owned) |parsed_cron_storage| { + defer parsed_cron_storage.deinit(allocator); + const parsed_cron = parsed_cron_storage.borrowed(); return switch (parsed_cron.action) { .collection => if (std.mem.eql(u8, method, "GET")) handleCronList(allocator, s, paths, parsed_cron.component, parsed_cron.name) @@ -4989,12 +5124,24 @@ pub fn dispatch( }; } - if (parseChannelsPath(target)) |parsed_channels| { + const parsed_channels_owned = parseChannelsPathAlloc(allocator, target) catch |err| switch (err) { + error.InvalidPathSegment => return badRequest("{\"error\":\"invalid path segment\"}"), + else => return helpers.serverError(), + }; + if (parsed_channels_owned) |parsed_channels_storage| { + defer parsed_channels_storage.deinit(allocator); + const parsed_channels = parsed_channels_storage.borrowed(); if (!std.mem.eql(u8, method, "GET")) return methodNotAllowed(); return handleChannels(allocator, s, paths, parsed_channels.component, parsed_channels.name, parsed_channels.channel_type); } - const parsed = parsePath(target) orelse return null; + const parsed_owned = parsePathAlloc(allocator, target) catch |err| switch (err) { + error.InvalidPathSegment => return badRequest("{\"error\":\"invalid path segment\"}"), + else => return helpers.serverError(), + }; + const parsed_storage = parsed_owned orelse return null; + defer parsed_storage.deinit(allocator); + const parsed = parsed_storage.borrowed(); if (parsed.action) |action| { if (std.mem.eql(u8, action, "status")) { @@ -5852,6 +5999,36 @@ test "parsePath: onboarding action" { try std.testing.expectEqualStrings("onboarding", p.action.?); } +test "parsePathAlloc decodes percent-encoded names" { + const allocator = std.testing.allocator; + const parsed = (try parsePathAlloc(allocator, "/api/instances/nullclaw/Opencode%20Go/provider-health")).?; + defer parsed.deinit(allocator); + + try std.testing.expectEqualStrings("nullclaw", parsed.component); + try std.testing.expectEqualStrings("Opencode Go", parsed.name); + try std.testing.expectEqualStrings("provider-health", parsed.action.?); +} + +test "parsePathAlloc decodes additional percent-encoded special characters" { + const allocator = std.testing.allocator; + const parsed = (try parsePathAlloc(allocator, "/api/instances/nullclaw/NullClaw%20MiMo%20%28beta%29%20%231/status")).?; + defer parsed.deinit(allocator); + + try std.testing.expectEqualStrings("nullclaw", parsed.component); + try std.testing.expectEqualStrings("NullClaw MiMo (beta) #1", parsed.name); + try std.testing.expectEqualStrings("status", parsed.action.?); +} + +test "parseChannelsPathAlloc decodes percent-encoded names" { + const allocator = std.testing.allocator; + const parsed = (try parseChannelsPathAlloc(allocator, "/api/instances/nullclaw/Opencode%20Go/channels/telegram")).?; + defer parsed.deinit(allocator); + + try std.testing.expectEqualStrings("nullclaw", parsed.component); + try std.testing.expectEqualStrings("Opencode Go", parsed.name); + try std.testing.expectEqualStrings("telegram", parsed.channel_type.?); +} + test "parseChannelsPath: collection route" { const p = parseChannelsPath("/api/instances/nullclaw/default/channels").?; try std.testing.expectEqualStrings("nullclaw", p.component); @@ -5879,6 +6056,17 @@ test "parseCronPath: run route" { try std.testing.expectEqual(p.action, .run); } +test "parseCronPathAlloc decodes percent-encoded names and job ids" { + const allocator = std.testing.allocator; + const parsed = (try parseCronPathAlloc(allocator, "/api/instances/nullclaw/Opencode%20Go/cron/job%201/run")).?; + defer parsed.deinit(allocator); + + try std.testing.expectEqualStrings("nullclaw", parsed.component); + try std.testing.expectEqualStrings("Opencode Go", parsed.name); + try std.testing.expectEqualStrings("job 1", parsed.job_id.?); + try std.testing.expectEqual(parsed.action, .run); +} + test "parseCronPath: rejects unknown verb" { try std.testing.expect(parseCronPath("/api/instances/nullclaw/default/cron/job-1/nope") == null); } @@ -6381,6 +6569,65 @@ test "handleDelete removes instance" { try std.testing.expect(s.getInstance("nullclaw", "my-agent") == null); } +test "dispatch gets instance with percent-encoded name" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.0" }); + + const resp = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "GET", + "/api/instances/nullclaw/Opencode%20Go", + "", + ).?; + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "Opencode Go") != null); +} + +test "dispatch deletes instance with percent-encoded name" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.0" }); + try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "Opencode Go", "{\"gateway\":{\"port\":3000}}"); + + const resp = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "DELETE", + "/api/instances/nullclaw/Opencode%20Go", + "", + ).?; + + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expectEqualStrings("{\"status\":\"deleted\"}", resp.body); + try std.testing.expect(s.getInstance("nullclaw", "Opencode Go") == null); +} + test "handleDelete removes instance directory from active path" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); @@ -6967,6 +7214,43 @@ test "dispatch routes GET cron action via nullclaw CLI when available" { try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"id\":\"job-cli\"") != null); } +test "dispatch routes GET cron action with percent-encoded instance name" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.0" }); + try writeTestBinary( + allocator, + mctx.paths, + "nullclaw", + "1.0.0", + \\#!/bin/sh + \\set -eu + \\if [ "$1" = "cron" ] && [ "$2" = "list" ] && [ "$3" = "--json" ]; then + \\ printf '%s\n' '[{"id":"job-cli","expression":"*/10 * * * *","command":"echo cli","paused":false,"one_shot":false}]' + \\ exit 0 + \\fi + \\exit 64 + , + ); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/Opencode%20Go/cron", "").?; + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"jobs\":[") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"id\":\"job-cli\"") != null); +} + test "dispatch routes POST cron create action" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; @@ -7018,6 +7302,70 @@ test "dispatch routes POST cron create action" { try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"command\":\"echo hello\"") != null); } +test "dispatch routes POST cron pause action with percent-encoded instance name" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.0" }); + const cron_path = try std.fs.path.join(allocator, &.{ mctx.paths.root, "instances", "nullclaw", "Opencode Go", "cron.json" }); + defer allocator.free(cron_path); + try ensurePath(std.fs.path.dirname(cron_path).?); + const cron_file = try std_compat.fs.createFileAbsolute(cron_path, .{ .truncate = true }); + defer cron_file.close(); + try cron_file.writeAll( + \\[ + \\ {"id":"job 1","expression":"*/20 * * * *","command":"echo go heartbeat","paused":false,"one_shot":false,"job_type":"shell","enabled":true,"delete_after_run":false} + \\] + ); + + try writeTestBinary( + allocator, + mctx.paths, + "nullclaw", + "1.0.0", + \\#!/bin/sh + \\set -eu + \\home="${NULLCLAW_HOME:?}" + \\if [ "$1" = "cron" ] && [ "$2" = "pause" ] && [ "$3" = "job 1" ]; then + \\ cat > "${home}/cron.json" <<'EOF' + \\[ + \\ {"id":"job 1","expression":"*/20 * * * *","command":"echo go heartbeat","paused":true,"one_shot":false,"job_type":"shell","enabled":true,"delete_after_run":false} + \\] + \\EOF + \\ exit 0 + \\fi + \\echo "unexpected args: $*" >&2 + \\exit 1 + , + ); + + const resp = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "POST", + "/api/instances/nullclaw/Opencode%20Go/cron/job%201/pause", + "", + ).?; + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"status\":\"paused\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"id\":\"job 1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"paused\":true") != null); +} + test "handleOnboarding reports pending bootstrap for fresh nullclaw workspace" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); @@ -7198,6 +7546,37 @@ test "dispatch routes GET integration action for linked nullboiler" { try std.testing.expectEqual(@as(i64, 2), current_link.get("max_concurrent_tasks").?.integer); } +test "dispatch routes GET integration action with percent-encoded instance name" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullwatch", "observer-a", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.0" }); + + try writeTestInstanceConfig(allocator, mctx.paths, "nullwatch", "observer-a", "{\"host\":\"127.0.0.1\",\"port\":7711,\"api_token\":\"watch-token\"}"); + try writeTestInstanceConfig( + allocator, + mctx.paths, + "nullclaw", + "Opencode Go", + "{\"diagnostics\":{\"backend\":\"otel\",\"otel\":{\"endpoint\":\"http://127.0.0.1:7711\",\"service_name\":\"nullclaw/Opencode Go\",\"headers\":{\"Authorization\":\"Bearer watch-token\",\"x-nullwatch-source\":\"nullclaw\"}}}}", + ); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/Opencode%20Go/integration", "").?; + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"kind\":\"nullclaw\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"observer-a\"") != null); +} + test "dispatch routes nulltickets tickets action to managed instances only" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); @@ -7801,6 +8180,36 @@ test "handleHistory returns CLI JSON and passes instance home" { try std.testing.expect(std.mem.indexOf(u8, show_resp.body, "\"role\":\"user\"") != null); } +test "dispatch routes GET history action with percent-encoded instance name" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.0" }); + const script = + \\#!/bin/sh + \\if [ "$1" = "history" ] && [ "$2" = "list" ]; then + \\ printf '%s\n' '{"total":1,"limit":50,"offset":0,"sessions":[{"session_id":"s-1","message_count":2,"first_message_at":"2026-03-10T10:00:00Z","last_message_at":"2026-03-10T10:01:00Z"}]}' + \\ exit 0 + \\fi + \\echo "unexpected args" >&2 + \\exit 1 + \\ + ; + try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.0", script); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/Opencode%20Go/history?limit=50&offset=0", "").?; + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"session_id\":\"s-1\"") != null); +} + test "handleMemory wraps CLI failures as JSON errors" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); @@ -8040,6 +8449,36 @@ test "handleSkills returns 404 when CLI detail returns null" { try std.testing.expectEqualStrings("404 Not Found", resp.status); } +test "dispatch routes GET skills action with percent-encoded instance name" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.2-skill-null" }); + const script = + \\#!/bin/sh + \\if [ "$1" = "skills" ] && [ "$2" = "info" ] && [ "$3" = "missing" ] && [ "$4" = "--json" ]; then + \\ printf '%s\n' 'null' + \\ exit 0 + \\fi + \\echo "unexpected args" >&2 + \\exit 1 + \\ + ; + try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.2-skill-null", script); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/Opencode%20Go/skills?name=missing", "").?; + try std.testing.expectEqualStrings("404 Not Found", resp.status); +} + test "dispatch routes GET channels action" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); @@ -8071,6 +8510,71 @@ test "dispatch routes GET channels action" { try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"account_id\":\"main\"") != null); } +test "dispatch routes GET channels action with percent-encoded instance name" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.2" }); + const script = + \\#!/bin/sh + \\if [ "$1" = "channel" ] && [ "$2" = "list" ] && [ "$3" = "--json" ]; then + \\ printf '%s\n' '[{"type":"telegram","account_id":"main","configured":true,"status":"ok"}]' + \\ exit 0 + \\fi + \\echo "unexpected args" >&2 + \\exit 1 + \\ + ; + try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.2", script); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/Opencode%20Go/channels", "").?; + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"type\":\"telegram\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"account_id\":\"main\"") != null); +} + +test "dispatch routes GET channel detail with percent-encoded instance name" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.2-detail" }); + const script = + \\#!/bin/sh + \\if [ "$1" = "channel" ] && [ "$2" = "info" ] && [ "$3" = "telegram" ] && [ "$4" = "--json" ]; then + \\ printf '%s\n' '{"type":"telegram","status":"ok","accounts":[{"account_id":"main","configured":true,"status":"ok"}]}' + \\ exit 0 + \\fi + \\echo "unexpected args" >&2 + \\exit 1 + \\ + ; + try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.2-detail", script); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/Opencode%20Go/channels/telegram", "").?; + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"type\":\"telegram\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"account_id\":\"main\"") != null); +} + test "dispatch routes GET channel detail maps missing type to 404" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; @@ -8705,6 +9209,57 @@ test "dispatch routes agent invoke stream and sessions" { try std.testing.expect(std.mem.indexOf(u8, delete_resp.body, "\"terminated\":true") != null); } +test "dispatch routes agent sessions with percent-encoded instance name" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.10" }); + const script = + \\#!/bin/sh + \\set -eu + \\if [ "$1" = "agent" ] && [ "$2" = "sessions" ] && [ "$3" = "list" ] && [ "$4" = "--json" ]; then + \\ printf '%s\n' '{"sessions":[{"session_key":"s-1","created_at":"2026-04-17T00:00:00Z","last_active":"2026-04-17T00:01:00Z","turn_count":1,"turn_running":false}],"total":1}' + \\ exit 0 + \\fi + \\if [ "$1" = "agent" ] && [ "$2" = "sessions" ] && [ "$3" = "get" ] && [ "$4" = "s-1" ] && [ "$5" = "--json" ]; then + \\ printf '%s\n' '{"session_key":"s-1","created_at":"2026-04-17T00:00:00Z","last_active":"2026-04-17T00:01:00Z","turn_count":1,"turn_running":false}' + \\ exit 0 + \\fi + \\if [ "$1" = "agent" ] && [ "$2" = "sessions" ] && [ "$3" = "terminate" ] && [ "$4" = "s-1" ] && [ "$5" = "--json" ]; then + \\ printf '%s\n' '{"session_key":"s-1","terminated":true}' + \\ exit 0 + \\fi + \\echo "unexpected args: $*" >&2 + \\exit 1 + \\ + ; + try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.10", script); + + const list_resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/Opencode%20Go/agent-sessions", "").?; + defer allocator.free(list_resp.body); + try std.testing.expectEqualStrings("200 OK", list_resp.status); + try std.testing.expect(std.mem.indexOf(u8, list_resp.body, "\"session_key\":\"s-1\"") != null); + + const get_resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/Opencode%20Go/agent-sessions?session_id=s-1", "").?; + defer allocator.free(get_resp.body); + try std.testing.expectEqualStrings("200 OK", get_resp.status); + try std.testing.expect(std.mem.indexOf(u8, get_resp.body, "\"turn_count\":1") != null); + + const delete_resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "DELETE", "/api/instances/nullclaw/Opencode%20Go/agent-sessions?session_id=s-1", "").?; + defer allocator.free(delete_resp.body); + try std.testing.expectEqualStrings("200 OK", delete_resp.status); + try std.testing.expect(std.mem.indexOf(u8, delete_resp.body, "\"terminated\":true") != null); +} + test "dispatch routes memory write read stats and search actions" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; diff --git a/src/api/logs.zig b/src/api/logs.zig index b17d1e0..93f440b 100644 --- a/src/api/logs.zig +++ b/src/api/logs.zig @@ -45,6 +45,21 @@ pub const ParsedLogsPath = struct { is_stream: bool, }; +pub const ParsedLogsPathOwned = struct { + component: []u8, + name: []u8, + is_stream: bool, + + pub fn deinit(self: ParsedLogsPathOwned, allocator: std.mem.Allocator) void { + allocator.free(self.component); + allocator.free(self.name); + } + + pub fn borrowed(self: ParsedLogsPathOwned) ParsedLogsPath { + return .{ .component = self.component, .name = self.name, .is_stream = self.is_stream }; + } +}; + /// Parse /api/instances/{c}/{n}/logs or /api/instances/{c}/{n}/logs/stream. pub fn parseLogsPath(target: []const u8) ?ParsedLogsPath { const clean = stripQuery(target); @@ -76,6 +91,21 @@ pub fn parseLogsPath(target: []const u8) ?ParsedLogsPath { return .{ .component = component, .name = name, .is_stream = false }; } +pub fn parseLogsPathAlloc(allocator: std.mem.Allocator, target: []const u8) !?ParsedLogsPathOwned { + const parsed = try query.parseInstancePathPrefixAlloc(allocator, target) orelse return null; + errdefer parsed.deinit(allocator); + + if (std.mem.eql(u8, parsed.suffix, "logs")) { + return .{ .component = parsed.component, .name = parsed.name, .is_stream = false }; + } + if (std.mem.eql(u8, parsed.suffix, "logs/stream")) { + return .{ .component = parsed.component, .name = parsed.name, .is_stream = true }; + } + + parsed.deinit(allocator); + return null; +} + /// Check if a target path matches the logs pattern. pub fn isLogsPath(target: []const u8) bool { return parseLogsPath(target) != null; @@ -292,6 +322,15 @@ test "parseLogsPath: with query string" { try std.testing.expect(!p.is_stream); } +test "parseLogsPathAlloc decodes percent-encoded names" { + const allocator = std.testing.allocator; + const p = (try parseLogsPathAlloc(allocator, "/api/instances/nullclaw/Opencode%20Go/logs/stream?lines=50")).?; + defer p.deinit(allocator); + try std.testing.expectEqualStrings("nullclaw", p.component); + try std.testing.expectEqualStrings("Opencode Go", p.name); + try std.testing.expect(p.is_stream); +} + test "parseLogsPath: rejects non-logs path" { try std.testing.expect(parseLogsPath("/api/instances/nullclaw/my-agent/config") == null); } diff --git a/src/api/query.zig b/src/api/query.zig index 7d1619c..9850b9c 100644 --- a/src/api/query.zig +++ b/src/api/query.zig @@ -1,5 +1,16 @@ const std = @import("std"); +pub const ParsedInstancePathPrefixOwned = struct { + component: []u8, + name: []u8, + suffix: []const u8, + + pub fn deinit(self: ParsedInstancePathPrefixOwned, allocator: std.mem.Allocator) void { + allocator.free(self.component); + allocator.free(self.name); + } +}; + pub fn stripTarget(target: []const u8) []const u8 { if (std.mem.indexOfScalar(u8, target, '?')) |idx| { return target[0..idx]; @@ -22,6 +33,59 @@ pub fn valueRaw(target: []const u8, key: []const u8) ?[]const u8 { return null; } +pub fn decodePathSegmentAlloc(allocator: std.mem.Allocator, segment: []const u8) ![]u8 { + const encoded = try allocator.dupe(u8, segment); + errdefer allocator.free(encoded); + + const decoded = std.Uri.percentDecodeInPlace(encoded); + if (!isSafeDecodedPathSegment(decoded)) return error.InvalidPathSegment; + if (decoded.ptr == encoded.ptr and decoded.len == encoded.len) return encoded; + + const out = try allocator.dupe(u8, decoded); + allocator.free(encoded); + return out; +} + +fn isSafeDecodedPathSegment(segment: []const u8) bool { + if (segment.len == 0) return false; + if (std.mem.eql(u8, segment, ".") or std.mem.eql(u8, segment, "..")) return false; + for (segment) |byte| { + if (byte == 0 or byte == '/' or byte == '\\') return false; + } + return true; +} + +pub fn parseInstancePathPrefixAlloc(allocator: std.mem.Allocator, target: []const u8) !?ParsedInstancePathPrefixOwned { + const clean = stripTarget(target); + const prefix = "/api/instances/"; + if (!std.mem.startsWith(u8, clean, prefix)) return null; + + const rest = clean[prefix.len..]; + if (rest.len == 0) return null; + + const component_sep = std.mem.indexOfScalar(u8, rest, '/') orelse return null; + const component_raw = rest[0..component_sep]; + if (component_raw.len == 0) return null; + + const after_component = rest[component_sep + 1 ..]; + if (after_component.len == 0) return null; + + const name_sep = std.mem.indexOfScalar(u8, after_component, '/'); + const name_raw = if (name_sep) |idx| after_component[0..idx] else after_component; + if (name_raw.len == 0) return null; + + const component = try decodePathSegmentAlloc(allocator, component_raw); + errdefer allocator.free(component); + const name = try decodePathSegmentAlloc(allocator, name_raw); + errdefer allocator.free(name); + + return .{ + .component = component, + .name = name, + .suffix = if (name_sep) |idx| after_component[idx + 1 ..] else "", + }; +} + pub fn valueAlloc(allocator: std.mem.Allocator, target: []const u8, key: []const u8) !?[]u8 { const raw = valueRaw(target, key) orelse return null; @@ -57,6 +121,47 @@ test "valueAlloc decodes percent-encoded and plus-separated values" { try std.testing.expectEqualStrings("hello world/skills", value); } +test "parseInstancePathPrefixAlloc decodes component and name" { + const allocator = std.testing.allocator; + const parsed = (try parseInstancePathPrefixAlloc(allocator, "/api/instances/nullclaw/Opencode%20Go/config")).?; + defer parsed.deinit(allocator); + + try std.testing.expectEqualStrings("nullclaw", parsed.component); + try std.testing.expectEqualStrings("Opencode Go", parsed.name); + try std.testing.expectEqualStrings("config", parsed.suffix); +} + +test "parseInstancePathPrefixAlloc decodes additional percent-encoded path characters" { + const allocator = std.testing.allocator; + const parsed = (try parseInstancePathPrefixAlloc( + allocator, + "/api/instances/nullclaw/NullClaw%20MiMo%20%28beta%29%20%231/channels/web", + )).?; + defer parsed.deinit(allocator); + + try std.testing.expectEqualStrings("nullclaw", parsed.component); + try std.testing.expectEqualStrings("NullClaw MiMo (beta) #1", parsed.name); + try std.testing.expectEqualStrings("channels/web", parsed.suffix); +} + +test "parseInstancePathPrefixAlloc rejects decoded path separators" { + try std.testing.expectError( + error.InvalidPathSegment, + parseInstancePathPrefixAlloc(std.testing.allocator, "/api/instances/nullclaw/name%2Fwith%2Fslash/config"), + ); + try std.testing.expectError( + error.InvalidPathSegment, + parseInstancePathPrefixAlloc(std.testing.allocator, "/api/instances/nullclaw/name%5Cwith%5Cslash/config"), + ); +} + +test "parseInstancePathPrefixAlloc rejects decoded traversal segments" { + try std.testing.expectError( + error.InvalidPathSegment, + parseInstancePathPrefixAlloc(std.testing.allocator, "/api/instances/nullclaw/%2E%2E/config"), + ); +} + test "boolValue accepts common truthy forms" { try std.testing.expect(boolValue("/api/test?stats=1", "stats")); try std.testing.expect(boolValue("/api/test?stats=true", "stats")); diff --git a/src/api/updates.zig b/src/api/updates.zig index 5aab03d..47d07f4 100644 --- a/src/api/updates.zig +++ b/src/api/updates.zig @@ -9,6 +9,7 @@ const downloader = @import("../installer/downloader.zig"); const launch_args_mod = @import("../core/launch_args.zig"); const platform = @import("../core/platform.zig"); const helpers = @import("helpers.zig"); +const query = @import("query.zig"); const test_helpers = @import("../test_helpers.zig"); const ApiResponse = helpers.ApiResponse; @@ -24,6 +25,20 @@ pub const ParsedUpdatePath = struct { name: []const u8, }; +pub const ParsedUpdatePathOwned = struct { + component: []u8, + name: []u8, + + pub fn deinit(self: ParsedUpdatePathOwned, allocator: std.mem.Allocator) void { + allocator.free(self.component); + allocator.free(self.name); + } + + pub fn borrowed(self: ParsedUpdatePathOwned) ParsedUpdatePath { + return .{ .component = self.component, .name = self.name }; + } +}; + /// Parse `/api/instances/{component}/{name}/update` from a request target. /// Returns `null` if the path does not match. pub fn parseUpdatePath(target: []const u8) ?ParsedUpdatePath { @@ -49,6 +64,16 @@ pub fn parseUpdatePath(target: []const u8) ?ParsedUpdatePath { return .{ .component = component, .name = name }; } +pub fn parseUpdatePathAlloc(allocator: std.mem.Allocator, target: []const u8) !?ParsedUpdatePathOwned { + const parsed = try query.parseInstancePathPrefixAlloc(allocator, target) orelse return null; + errdefer parsed.deinit(allocator); + if (!std.mem.eql(u8, parsed.suffix, "update")) { + parsed.deinit(allocator); + return null; + } + return .{ .component = parsed.component, .name = parsed.name }; +} + fn stripV(v: []const u8) []const u8 { return if (std.mem.startsWith(u8, v, "v")) v[1..] else v; } @@ -401,6 +426,14 @@ test "parseUpdatePath extracts component and name correctly" { try std.testing.expectEqualStrings("my-agent", p.name); } +test "parseUpdatePathAlloc decodes percent-encoded names" { + const allocator = std.testing.allocator; + const p = (try parseUpdatePathAlloc(allocator, "/api/instances/nullclaw/Opencode%20Go/update")).?; + defer p.deinit(allocator); + try std.testing.expectEqualStrings("nullclaw", p.component); + try std.testing.expectEqualStrings("Opencode Go", p.name); +} + test "parseUpdatePath rejects wrong action" { try std.testing.expect(parseUpdatePath("/api/instances/nullclaw/my-agent/start") == null); } diff --git a/src/main.zig b/src/main.zig index 6488dd9..679988a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -152,7 +152,35 @@ fn printApiError(opts: cli.ApiOptions, err: anyerror) void { } fn instanceActionTarget(allocator: std.mem.Allocator, ref: cli.InstanceRef, action: []const u8) ![]u8 { - return std.fmt.allocPrint(allocator, "/api/instances/{s}/{s}/{s}", .{ ref.component, ref.name, action }); + var suffix = std.array_list.Managed(u8).init(allocator); + defer suffix.deinit(); + try suffix.append('/'); + try suffix.appendSlice(action); + return instanceTarget(allocator, ref, suffix.items); +} + +fn instanceTarget(allocator: std.mem.Allocator, ref: cli.InstanceRef, suffix: []const u8) ![]u8 { + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice("/api/instances/"); + try appendUrlPathSegment(&buf, ref.component); + try buf.append('/'); + try appendUrlPathSegment(&buf, ref.name); + try buf.appendSlice(suffix); + return try buf.toOwnedSlice(); +} + +fn appendUrlPathSegment(buf: *std.array_list.Managed(u8), value: []const u8) !void { + const hex = "0123456789ABCDEF"; + for (value) |byte| { + if (std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '_' or byte == '.' or byte == '~') { + try buf.append(byte); + } else { + try buf.append('%'); + try buf.append(hex[byte >> 4]); + try buf.append(hex[byte & 0x0f]); + } + } } fn runInstanceAction(allocator: std.mem.Allocator, ref: cli.InstanceRef, action: []const u8) void { @@ -196,11 +224,12 @@ fn runLogsCommand(allocator: std.mem.Allocator, opts: cli.LogsOptions) void { if (opts.follow) { std.debug.print("nullhub logs -f is not stream-backed yet; showing current logs.\n", .{}); } - const target = std.fmt.allocPrint( - allocator, - "/api/instances/{s}/{s}/logs?lines={d}", - .{ opts.instance.component, opts.instance.name, opts.lines }, - ) catch { + const suffix = std.fmt.allocPrint(allocator, "/logs?lines={d}", .{opts.lines}) catch { + std.debug.print("failed to build API target\n", .{}); + std.process.exit(1); + }; + defer allocator.free(suffix); + const target = instanceTarget(allocator, opts.instance, suffix) catch { std.debug.print("failed to build API target\n", .{}); std.process.exit(1); }; @@ -212,11 +241,7 @@ fn runConfigCommand(allocator: std.mem.Allocator, opts: cli.ConfigOptions) void if (opts.edit) { std.debug.print("nullhub config --edit is not stream-backed yet; showing current config.\n", .{}); } - const target = std.fmt.allocPrint( - allocator, - "/api/instances/{s}/{s}/config", - .{ opts.instance.component, opts.instance.name }, - ) catch { + const target = instanceTarget(allocator, opts.instance, "/config") catch { std.debug.print("failed to build API target\n", .{}); std.process.exit(1); }; @@ -226,11 +251,7 @@ fn runConfigCommand(allocator: std.mem.Allocator, opts: cli.ConfigOptions) void fn runUninstallCommand(allocator: std.mem.Allocator, opts: cli.UninstallOptions) void { _ = opts.remove_data; - const target = std.fmt.allocPrint( - allocator, - "/api/instances/{s}/{s}", - .{ opts.instance.component, opts.instance.name }, - ) catch { + const target = instanceTarget(allocator, opts.instance, "") catch { std.debug.print("failed to build API target\n", .{}); std.process.exit(1); }; diff --git a/src/server.zig b/src/server.zig index ffc87d4..efb425b 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1637,7 +1637,13 @@ pub const Server = struct { // Config API — /api/instances/{c}/{n}/config if (config_api.isConfigPath(target)) { - if (config_api.parseConfigPath(target)) |parsed| { + const parsed_owned = config_api.parseConfigPathAlloc(allocator, target) catch |err| switch (err) { + error.InvalidPathSegment => return .{ .status = "400 Bad Request", .content_type = "application/json", .body = "{\"error\":\"invalid path segment\"}" }, + else => return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }, + }; + if (parsed_owned) |parsed_storage| { + defer parsed_storage.deinit(allocator); + const parsed = parsed_storage.borrowed(); if (std.mem.eql(u8, method, "GET")) { const resp = config_api.handleGetManaged(allocator, self.state, self.paths, parsed.component, parsed.name, target); return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; @@ -1660,7 +1666,13 @@ pub const Server = struct { // Logs API — /api/instances/{c}/{n}/logs and /api/instances/{c}/{n}/logs/stream if (logs_api.isLogsPath(target)) { - if (logs_api.parseLogsPath(target)) |parsed| { + const parsed_owned = logs_api.parseLogsPathAlloc(allocator, target) catch |err| switch (err) { + error.InvalidPathSegment => return .{ .status = "400 Bad Request", .content_type = "application/json", .body = "{\"error\":\"invalid path segment\"}" }, + else => return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }, + }; + if (parsed_owned) |parsed_storage| { + defer parsed_storage.deinit(allocator); + const parsed = parsed_storage.borrowed(); if (std.mem.eql(u8, method, "DELETE")) { const source = logs_api.parseSource(target); const resp = logs_api.handleDelete(allocator, self.paths, parsed.component, parsed.name, source); @@ -1689,7 +1701,13 @@ pub const Server = struct { // Instances API — delegate to instances_api.dispatch and updates_api. if (std.mem.startsWith(u8, target, "/api/instances")) { // Updates API — POST /api/instances/{c}/{n}/update - if (updates_api.parseUpdatePath(target)) |up| { + const update_owned = updates_api.parseUpdatePathAlloc(allocator, target) catch |err| switch (err) { + error.InvalidPathSegment => return .{ .status = "400 Bad Request", .content_type = "application/json", .body = "{\"error\":\"invalid path segment\"}" }, + else => return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }, + }; + if (update_owned) |update_storage| { + defer update_storage.deinit(allocator); + const up = update_storage.borrowed(); if (std.mem.eql(u8, method, "POST")) { const ur = updates_api.handleApplyUpdateRuntime( allocator, @@ -3348,6 +3366,73 @@ test "route POST /api/instances/{c}/{n}/update returns 404 for empty state" { try std.testing.expectEqualStrings("404 Not Found", resp.status); } +test "route GET config supports percent-encoded instance names" { + const allocator = std.testing.allocator; + var ctx = TestContext.init(allocator); + defer ctx.deinit(allocator); + try ctx.paths.ensureDirs(); + + const inst_dir = try ctx.paths.instanceDir(allocator, "nullclaw", "Opencode Go"); + defer allocator.free(inst_dir); + try std_compat.fs.makeDirAbsolute(inst_dir); + + const config_path = try ctx.paths.instanceConfig(allocator, "nullclaw", "Opencode Go"); + defer allocator.free(config_path); + var file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"gateway\":{\"port\":3000}}"); + + const resp = ctx.route(allocator, "GET", "/api/instances/nullclaw/Opencode%20Go/config", ""); + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "3000") != null); +} + +test "route GET config rejects decoded traversal instance names" { + const allocator = std.testing.allocator; + var ctx = TestContext.init(allocator); + defer ctx.deinit(allocator); + + const resp = ctx.route(allocator, "GET", "/api/instances/nullclaw/%2E%2E/config", ""); + try std.testing.expectEqualStrings("400 Bad Request", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"invalid path segment\"}", resp.body); +} + +test "route GET logs supports percent-encoded instance names" { + const allocator = std.testing.allocator; + var ctx = TestContext.init(allocator); + defer ctx.deinit(allocator); + try ctx.paths.ensureDirs(); + + const logs_dir = try ctx.paths.instanceLogs(allocator, "nullclaw", "Opencode Go"); + defer allocator.free(logs_dir); + try std_compat.fs.makeDirAbsolute(logs_dir); + + const log_path = try std.fs.path.join(allocator, &.{ logs_dir, "stdout.log" }); + defer allocator.free(log_path); + var file = try std_compat.fs.createFileAbsolute(log_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("hello\n"); + + const resp = ctx.route(allocator, "GET", "/api/instances/nullclaw/Opencode%20Go/logs", ""); + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "hello") != null); +} + +test "route POST update supports percent-encoded instance names" { + const allocator = std.testing.allocator; + var ctx = TestContext.init(allocator); + defer ctx.deinit(allocator); + + try ctx.state.addInstance("nullclaw", "Opencode Go", .{ .version = "1.0.0" }); + + const resp = ctx.route(allocator, "POST", "/api/instances/nullclaw/Opencode%20Go/update", ""); + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "Opencode Go") != null); +} + test "Server init sets fields" { const paths = try paths_mod.Paths.init(std.testing.allocator, null); var mgr = manager_mod.Manager.init(std.testing.allocator, paths);