faberun 0.26.3 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,503 @@
1
+ //! Faberun's fx client: one prompt on stdin, one JSONL transcript on stdout,
2
+ //! no interactive surface. It replaces the Node client (`../runner.mjs`) with
3
+ //! the same transcript, so the adapter and the controller cannot tell them
4
+ //! apart; measured 2026-09-24, that Node process peaked at 83 MB resident
5
+ //! beside a 9 MB `fx acp`, most of an fx worker's footprint.
6
+ //!
7
+ //! Why ACP instead of `fx ask --json`: `ask` prints its JSON once, at exit, and
8
+ //! its only permission choices are "everything" or "let the model review
9
+ //! itself". `fx acp` streams every tool call as it happens and, in `ask` mode,
10
+ //! sends each shell command and file mutation to this client as
11
+ //! `session/request_permission`, so the contract's sandbox is decided here.
12
+ //!
13
+ //! Transcript, the only thing this process writes to stdout:
14
+ //! {"type":"fx.started","sessionId":string}
15
+ //! {"type":"fx.tool","name":string,"target":string|null} one per tool call
16
+ //! {"type":"fx.request","status":number,"usage":Usage|null} one per provider request
17
+ //! {"type":"fx.message","text":string} one per non-empty assistant message
18
+ //! {"type":"fx.completed","sessionId":string,"result":string,"usage":Usage|null}
19
+ //! {"type":"fx.failed","sessionId":string|null,"kind":string,"error":{...},"usage":Usage|null}
20
+
21
+ const std = @import("std");
22
+ const Io = std.Io;
23
+ const home = @import("home.zig");
24
+ const permissions = @import("permissions.zig");
25
+ const relay_mod = @import("relay.zig");
26
+ const Usage = @import("usage.zig").Usage;
27
+
28
+ /// The provider name the generated settings give the relay connection.
29
+ const provider = "faberun";
30
+ /// fx's built-in ChatGPT-subscription provider, and the Responses endpoint it
31
+ /// posts to; the runner's relay stands in for the host.
32
+ const codex_provider = "codex";
33
+ const codex_upstream = "https://chatgpt.com/backend-api/codex";
34
+
35
+ /// Which of fx's provider paths the worker takes. `openai_compatible` is a
36
+ /// custom Chat Completions connection (DeepSeek, Z.ai) authenticated by a key
37
+ /// in the environment; `codex` is fx's ChatGPT login, which fx-faberun reads
38
+ /// from the operator's real profile through `FX_AUTH_HOME`.
39
+ const ProviderKind = enum { openai_compatible, codex };
40
+ /// Measured: DeepSeek's `max_tokens` ceiling on deepseek-flash; fx needs a declared value.
41
+ /// Medido: o teto de saida que o DeepSeek aceita em `deepseek-flash` e' maior que
42
+ /// 8192, mas o valor precisa ser declarado. E' o default, nao a lei: um runtime
43
+ /// pode declarar `max_output_tokens`. Medido em 2026-09-27: um rascunho de plano
44
+ /// do faberun nao cabe em 8192 tokens de saida.
45
+ const default_max_output_tokens = 8192;
46
+
47
+ const Options = struct {
48
+ fx: []const u8 = "fx",
49
+ model: []const u8 = "",
50
+ sandbox: []const u8 = "workspace-write",
51
+ provider_kind: ProviderKind = .openai_compatible,
52
+ base_url: ?[]const u8 = null,
53
+ key_env: []const u8 = "DEEPSEEK_API_KEY",
54
+ context_window: u64 = 1_000_000,
55
+ max_output_tokens: u64 = default_max_output_tokens,
56
+ };
57
+
58
+ const ProviderFailure = struct {
59
+ status: u16,
60
+ body: []const u8,
61
+ retry_after_ms: ?u64,
62
+ };
63
+
64
+ /// State the relay threads and the ACP loop share.
65
+ const Shared = struct {
66
+ io: Io,
67
+ gpa: std.mem.Allocator,
68
+ mutex: Io.Mutex = .init,
69
+ usage: Usage = .{},
70
+ saw_usage: bool = false,
71
+ last_failure: ?ProviderFailure = null,
72
+
73
+ /// Whole-line writes to fd 1 under one lock, so relay threads and the ACP
74
+ /// loop never interleave a record.
75
+ fn emit(self: *Shared, event: anytype) void {
76
+ var buffer: std.Io.Writer.Allocating = .init(self.gpa);
77
+ defer buffer.deinit();
78
+ std.json.Stringify.value(event, .{}, &buffer.writer) catch return;
79
+ buffer.writer.writeByte('\n') catch return;
80
+ self.mutex.lockUncancelable(self.io);
81
+ defer self.mutex.unlock(self.io);
82
+ Io.File.stdout().writeStreamingAll(self.io, buffer.written()) catch {};
83
+ }
84
+
85
+ fn onRequest(context: *anyopaque, request: relay_mod.Relayed) void {
86
+ const self: *Shared = @ptrCast(@alignCast(context));
87
+ {
88
+ self.mutex.lockUncancelable(self.io);
89
+ defer self.mutex.unlock(self.io);
90
+ if (request.usage) |found| {
91
+ self.usage.add(found);
92
+ self.saw_usage = true;
93
+ }
94
+ if (self.last_failure) |old| self.gpa.free(old.body);
95
+ self.last_failure = null;
96
+ if (request.status >= 400) {
97
+ const body = self.gpa.dupe(u8, request.error_body orelse "") catch "";
98
+ self.last_failure = .{ .status = request.status, .body = body, .retry_after_ms = request.retry_after_ms };
99
+ }
100
+ }
101
+ self.emit(.{ .type = "fx.request", .status = request.status, .usage = request.usage });
102
+ }
103
+ };
104
+
105
+ fn parseArgs(args: []const [:0]const u8) !Options {
106
+ var options: Options = .{};
107
+ var index: usize = 0;
108
+ while (index < args.len) : (index += 2) {
109
+ const flag = args[index];
110
+ if (index + 1 >= args.len) return fatal("{s} needs a value", .{flag});
111
+ const value: []const u8 = args[index + 1];
112
+ if (std.mem.eql(u8, flag, "--fx")) options.fx = value else if (std.mem.eql(u8, flag, "--model")) options.model = value else if (std.mem.eql(u8, flag, "--sandbox")) options.sandbox = value else if (std.mem.eql(u8, flag, "--provider")) options.provider_kind = parseProviderKind(value) orelse return fatal("unknown provider: {s}", .{value}) else if (std.mem.eql(u8, flag, "--base-url")) options.base_url = value else if (std.mem.eql(u8, flag, "--key-env")) options.key_env = value else if (std.mem.eql(u8, flag, "--context-window")) options.context_window = try std.fmt.parseInt(u64, value, 10) else if (std.mem.eql(u8, flag, "--max-output-tokens")) options.max_output_tokens = try std.fmt.parseInt(u64, value, 10) else return fatal("unknown argument: {s}", .{flag});
113
+ }
114
+ if (options.model.len == 0) return fatal("--model is required", .{});
115
+ return options;
116
+ }
117
+
118
+ fn parseProviderKind(value: []const u8) ?ProviderKind {
119
+ if (std.mem.eql(u8, value, "openai-compatible")) return .openai_compatible;
120
+ if (std.mem.eql(u8, value, "codex")) return .codex;
121
+ return null;
122
+ }
123
+
124
+ fn fatal(comptime format: []const u8, args: anytype) error{Usage} {
125
+ std.debug.print(format ++ "\n", args);
126
+ return error.Usage;
127
+ }
128
+
129
+ /// The failure a provider status means. 402 is DeepSeek's "Insufficient
130
+ /// Balance" and 429 its rate limit; both name the exhaustion the declared
131
+ /// failover edge exists for.
132
+ fn providerCode(arena: std.mem.Allocator, status: u16) ![]const u8 {
133
+ return switch (status) {
134
+ 429 => "RATE_LIMIT",
135
+ 402 => "QUOTA",
136
+ else => try std.fmt.allocPrint(arena, "http_{d}", .{status}),
137
+ };
138
+ }
139
+
140
+ const Runner = struct {
141
+ arena: std.mem.Allocator,
142
+ io: Io,
143
+ shared: *Shared,
144
+ options: Options,
145
+ workspace: []const u8,
146
+ home_dir: []const u8,
147
+ relay: *relay_mod.Relay,
148
+ child: *std.process.Child,
149
+ session_id: ?[]const u8 = null,
150
+ segment: std.ArrayList(u8) = .empty,
151
+ last_text: []const u8 = "",
152
+
153
+ fn send(self: *Runner, message: anytype) !void {
154
+ var buffer: std.Io.Writer.Allocating = .init(self.arena);
155
+ try std.json.Stringify.value(message, .{ .emit_null_optional_fields = false }, &buffer.writer);
156
+ try buffer.writer.writeByte('\n');
157
+ try self.child.stdin.?.writeStreamingAll(self.io, buffer.written());
158
+ }
159
+
160
+ fn flushSegment(self: *Runner) void {
161
+ if (std.mem.trim(u8, self.segment.items, " \t\r\n").len == 0) return;
162
+ const text = self.arena.dupe(u8, self.segment.items) catch return;
163
+ self.last_text = text;
164
+ self.shared.emit(.{ .type = "fx.message", .text = text });
165
+ self.segment.clearRetainingCapacity();
166
+ }
167
+
168
+ fn usageOrNull(self: *Runner) ?Usage {
169
+ self.shared.mutex.lockUncancelable(self.io);
170
+ defer self.shared.mutex.unlock(self.io);
171
+ return if (self.shared.saw_usage) self.shared.usage else null;
172
+ }
173
+
174
+ fn cleanup(self: *Runner) void {
175
+ self.child.kill(self.io);
176
+ self.relay.stop();
177
+ Io.Dir.cwd().deleteTree(self.io, self.home_dir) catch {};
178
+ }
179
+
180
+ fn complete(self: *Runner) noreturn {
181
+ self.flushSegment();
182
+ self.shared.emit(.{ .type = "fx.completed", .sessionId = self.session_id, .result = self.last_text, .usage = self.usageOrNull() });
183
+ self.cleanup();
184
+ std.process.exit(0);
185
+ }
186
+
187
+ /// A provider status beats fx's prose about it: it is the fact failover keys on.
188
+ fn fail(self: *Runner, kind: []const u8, code: []const u8, message: []const u8) noreturn {
189
+ self.flushSegment();
190
+ const failure = blk: {
191
+ self.shared.mutex.lockUncancelable(self.io);
192
+ defer self.shared.mutex.unlock(self.io);
193
+ break :blk self.shared.last_failure;
194
+ };
195
+ const Error = struct { code: []const u8, message: []const u8, retryAfterMs: ?u64 = null };
196
+ const err: Error = if (failure) |f| .{
197
+ .code = providerCode(self.arena, f.status) catch "http_error",
198
+ .message = std.fmt.allocPrint(self.arena, "{d} {s}", .{ f.status, f.body[0..@min(f.body.len, 500)] }) catch "provider error",
199
+ .retryAfterMs = f.retry_after_ms,
200
+ } else .{ .code = code, .message = message };
201
+ self.shared.emit(.{
202
+ .type = "fx.failed",
203
+ .sessionId = self.session_id,
204
+ .kind = if (failure != null) "error" else kind,
205
+ .@"error" = err,
206
+ .usage = self.usageOrNull(),
207
+ });
208
+ self.cleanup();
209
+ std.process.exit(1);
210
+ }
211
+
212
+ fn onPermission(self: *Runner, id: std.json.Value, params: std.json.ObjectMap) !void {
213
+ const tool_call = objectField(params, "toolCall");
214
+ const kind = if (tool_call) |call| stringField(call, "kind") orelse "other" else "other";
215
+ const path = if (tool_call) |call| if (objectField(call, "rawInput")) |input| stringField(input, "path") else null else null;
216
+ const decision = try permissions.verdict(self.arena, self.options.sandbox, self.workspace, kind, path);
217
+ const wanted = if (decision.allow) "allow_once" else "reject_once";
218
+ if (!decision.allow) {
219
+ const title = if (tool_call) |call| stringField(call, "title") orelse "a tool call" else "a tool call";
220
+ std.debug.print("faberun denied {s}: {s}\n", .{ title, decision.reason orelse "" });
221
+ }
222
+ var option_id: ?[]const u8 = null;
223
+ if (params.get("options")) |options| if (options == .array) for (options.array.items) |option| {
224
+ if (option != .object) continue;
225
+ const option_kind = stringField(option.object, "kind") orelse continue;
226
+ if (std.mem.eql(u8, option_kind, wanted)) option_id = stringField(option.object, "optionId");
227
+ };
228
+ const Selected = struct { outcome: []const u8, optionId: ?[]const u8 = null };
229
+ try self.send(.{ .jsonrpc = "2.0", .id = id, .result = .{
230
+ .outcome = if (option_id) |value| Selected{ .outcome = "selected", .optionId = value } else Selected{ .outcome = "cancelled" },
231
+ } });
232
+ }
233
+
234
+ fn onUpdate(self: *Runner, update: std.json.ObjectMap) !void {
235
+ const kind = stringField(update, "sessionUpdate") orelse return;
236
+ if (std.mem.eql(u8, kind, "agent_message_chunk")) {
237
+ if (objectField(update, "content")) |content| if (stringField(content, "text")) |text| {
238
+ try self.segment.appendSlice(self.arena, text);
239
+ };
240
+ } else if (std.mem.eql(u8, kind, "tool_call")) {
241
+ // A tool call ends the message before it, the boundary a judge's
242
+ // verdicts are counted on.
243
+ self.flushSegment();
244
+ self.shared.emit(.{ .type = "fx.tool", .name = stringField(update, "kind") orelse "tool", .target = toolTarget(update) });
245
+ }
246
+ }
247
+ };
248
+
249
+ fn objectField(object: std.json.ObjectMap, key: []const u8) ?std.json.ObjectMap {
250
+ const value = object.get(key) orelse return null;
251
+ return if (value == .object) value.object else null;
252
+ }
253
+
254
+ fn stringField(object: std.json.ObjectMap, key: []const u8) ?[]const u8 {
255
+ const value = object.get(key) orelse return null;
256
+ return if (value == .string) value.string else null;
257
+ }
258
+
259
+ fn toolTarget(update: std.json.ObjectMap) ?[]const u8 {
260
+ const input = objectField(update, "rawInput") orelse return null;
261
+ for ([_][]const u8{ "path", "command", "pattern", "url" }) |key| {
262
+ if (stringField(input, key)) |value| return value[0..@min(value.len, 200)];
263
+ }
264
+ return null;
265
+ }
266
+
267
+ var child_pid = std.atomic.Value(i32).init(0);
268
+
269
+ /// The controller cancels a worker with SIGTERM; fx must not outlive it.
270
+ fn onSignal(signal: std.posix.SIG) callconv(.c) void {
271
+ const pid = child_pid.load(.monotonic);
272
+ if (pid > 0) std.posix.kill(pid, .TERM) catch {};
273
+ std.process.exit(@truncate(128 + @as(u32, @intFromEnum(signal))));
274
+ }
275
+
276
+ pub fn main(init: std.process.Init) !void {
277
+ const io = init.io;
278
+ const gpa = init.gpa;
279
+ const arena = init.arena.allocator();
280
+ const args = try init.minimal.args.toSlice(arena);
281
+ const options = parseArgs(args[1..]) catch std.process.exit(2);
282
+
283
+ var stdin_buffer: [64 * 1024]u8 = undefined;
284
+ var stdin = Io.File.stdin().readerStreaming(io, &stdin_buffer);
285
+ const prompt = try stdin.interface.allocRemaining(arena, .unlimited);
286
+
287
+ var cwd_buffer: [std.fs.max_path_bytes]u8 = undefined;
288
+ const cwd = cwd_buffer[0..try std.process.currentPath(io, &cwd_buffer)];
289
+ const workspace = try Io.Dir.realPathFileAbsoluteAlloc(io, cwd, arena);
290
+
291
+ var shared: Shared = .{ .io = io, .gpa = gpa };
292
+ const upstream = options.base_url orelse switch (options.provider_kind) {
293
+ .openai_compatible => "https://api.deepseek.com",
294
+ .codex => codex_upstream,
295
+ };
296
+ const relay = try relay_mod.start(gpa, io, upstream, .{ .context = &shared, .function = Shared.onRequest });
297
+
298
+ const real_home = init.environ_map.get("HOME") orelse return error.HomeNotSet;
299
+ const tmp_root = init.environ_map.get("TMPDIR") orelse "/tmp";
300
+ var nonce: [8]u8 = undefined;
301
+ io.random(&nonce);
302
+ const home_dir = try std.fmt.allocPrint(arena, "{s}/faberun-fx-home-{x}", .{ std.mem.trimEnd(u8, tmp_root, "/"), nonce });
303
+ try Io.Dir.cwd().createDirPath(io, home_dir);
304
+
305
+ const base_url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}", .{relay.port()});
306
+ var settings: std.Io.Writer.Allocating = .init(arena);
307
+ switch (options.provider_kind) {
308
+ .openai_compatible => {
309
+ var json: std.json.Stringify = .{ .writer = &settings.writer, .options = .{ .whitespace = .indent_2 } };
310
+ try json.beginObject();
311
+ try json.objectField("provider");
312
+ try json.write(provider);
313
+ try json.objectField("models");
314
+ try json.beginObject();
315
+ try json.objectField(provider);
316
+ try json.write(options.model);
317
+ try json.endObject();
318
+ try json.objectField("permission_mode");
319
+ try json.write("ask");
320
+ try json.objectField("providers");
321
+ try json.beginObject();
322
+ try json.objectField(provider);
323
+ try json.beginObject();
324
+ try json.objectField("protocol");
325
+ try json.write("openai-chat-completions");
326
+ try json.objectField("base_url");
327
+ try json.write(base_url);
328
+ try json.objectField("auth");
329
+ try json.write(.{ .type = "bearer", .env = options.key_env });
330
+ try json.objectField("model_metadata");
331
+ try json.beginObject();
332
+ try json.objectField(options.model);
333
+ try json.write(.{ .context_window = options.context_window, .max_output_tokens = options.max_output_tokens, .supports_tool_use = true });
334
+ try json.endObject();
335
+ try json.endObject();
336
+ try json.endObject();
337
+ try json.endObject();
338
+ try settings.writer.writeByte('\n');
339
+ },
340
+ .codex => {
341
+ var json: std.json.Stringify = .{ .writer = &settings.writer, .options = .{ .whitespace = .indent_2 } };
342
+ try json.beginObject();
343
+ try json.objectField("provider");
344
+ try json.write(codex_provider);
345
+ try json.objectField("models");
346
+ try json.beginObject();
347
+ try json.objectField(codex_provider);
348
+ try json.write(options.model);
349
+ try json.endObject();
350
+ try json.objectField("permission_mode");
351
+ try json.write("ask");
352
+ try json.endObject();
353
+ try settings.writer.writeByte('\n');
354
+ },
355
+ }
356
+ const settings_text = settings.written();
357
+ try home.prepare(arena, io, real_home, home_dir, settings_text);
358
+
359
+ var environ = try init.environ_map.clone(arena);
360
+ try environ.put("HOME", home_dir);
361
+ switch (options.provider_kind) {
362
+ .openai_compatible => try environ.put("FX_PROVIDER", provider),
363
+ .codex => {
364
+ try environ.put("FX_PROVIDER", codex_provider);
365
+ // The ChatGPT session stays in the operator's profile: a copy
366
+ // would diverge on the first token refresh (fx-faberun only).
367
+ try environ.put("FX_AUTH_HOME", real_home);
368
+ // fx accepts a loopback override of the Responses endpoint, which
369
+ // is how the relay meters this provider as it does the others.
370
+ try environ.put("FX_E2E_OPENAI_CODEX_RESPONSES_URL", try std.fmt.allocPrint(arena, "{s}/responses", .{base_url}));
371
+ },
372
+ }
373
+ try environ.put("FX_PERMISSION_MODE", "ask");
374
+ try environ.put("FX_AUTO_UPGRADE", "0");
375
+ // fx plays a sound on every send and response, which a detached campaign
376
+ // of parallel workers turns into noise on the operator's machine.
377
+ try environ.put("FX_SOUND", "0");
378
+
379
+ var child = std.process.spawn(io, .{
380
+ .argv = &.{ options.fx, "acp" },
381
+ .environ_map = &environ,
382
+ .stdin = .pipe,
383
+ .stdout = .pipe,
384
+ .stderr = .inherit,
385
+ }) catch |err| {
386
+ Io.Dir.cwd().deleteTree(io, home_dir) catch {};
387
+ shared.emit(.{ .type = "fx.failed", .sessionId = @as(?[]const u8, null), .kind = "harness_exit", .@"error" = .{
388
+ .code = "harness_exit",
389
+ .message = try std.fmt.allocPrint(arena, "cannot start {s}: {s}", .{ options.fx, @errorName(err) }),
390
+ }, .usage = @as(?Usage, null) });
391
+ std.process.exit(1);
392
+ };
393
+ if (child.id) |pid| child_pid.store(@intCast(pid), .monotonic);
394
+ const action: std.posix.Sigaction = .{ .handler = .{ .handler = onSignal }, .mask = std.posix.sigemptyset(), .flags = 0 };
395
+ std.posix.sigaction(.TERM, &action, null);
396
+ std.posix.sigaction(.INT, &action, null);
397
+
398
+ var runner: Runner = .{
399
+ .arena = arena,
400
+ .io = io,
401
+ .shared = &shared,
402
+ .options = options,
403
+ .workspace = workspace,
404
+ .home_dir = home_dir,
405
+ .relay = relay,
406
+ .child = &child,
407
+ };
408
+
409
+ try runner.send(.{ .jsonrpc = "2.0", .id = 1, .method = "initialize", .params = .{
410
+ .protocolVersion = 1,
411
+ .clientCapabilities = .{ .fs = .{ .readTextFile = false, .writeTextFile = false }, .terminal = false },
412
+ } });
413
+
414
+ var read_buffer: [64 * 1024]u8 = undefined;
415
+ var from_fx = child.stdout.?.readerStreaming(io, &read_buffer);
416
+ var line: std.Io.Writer.Allocating = .init(gpa);
417
+ defer line.deinit();
418
+ while (true) {
419
+ line.clearRetainingCapacity();
420
+ _ = from_fx.interface.streamDelimiter(&line.writer, '\n') catch |err| switch (err) {
421
+ error.EndOfStream => runner.fail("harness_exit", "harness_exit", "fx exited before the turn ended"),
422
+ else => runner.fail("harness_exit", "harness_exit", @errorName(err)),
423
+ };
424
+ from_fx.interface.toss(1);
425
+ if (std.mem.trim(u8, line.written(), " \t\r").len == 0) continue;
426
+ var message_arena = std.heap.ArenaAllocator.init(gpa);
427
+ defer message_arena.deinit();
428
+ const parsed = std.json.parseFromSliceLeaky(std.json.Value, message_arena.allocator(), line.written(), .{}) catch {
429
+ // stdout carries the JSON-RPC transport only; a non-JSON line is a
430
+ // harness defect, not provider prose, and must not be dropped.
431
+ runner.fail("invalid_protocol", "invalid_protocol", try std.fmt.allocPrint(arena, "fx wrote a non-JSON line: {s}", .{line.written()[0..@min(line.written().len, 200)]}));
432
+ };
433
+ if (parsed != .object) continue;
434
+ const message = parsed.object;
435
+ const method = stringField(message, "method");
436
+ const id = message.get("id");
437
+ if (method != null and id != null) {
438
+ if (std.mem.eql(u8, method.?, "session/request_permission")) {
439
+ try runner.onPermission(id.?, objectField(message, "params") orelse continue);
440
+ } else {
441
+ try runner.send(.{ .jsonrpc = "2.0", .id = id.?, .@"error" = .{ .code = -32601, .message = "faberun does not serve this method" } });
442
+ }
443
+ continue;
444
+ }
445
+ if (method) |name| {
446
+ if (std.mem.eql(u8, name, "session/update")) {
447
+ if (objectField(message, "params")) |params| if (objectField(params, "update")) |update| try runner.onUpdate(update);
448
+ }
449
+ continue;
450
+ }
451
+ const reply_id = if (id) |value| if (value == .integer) value.integer else continue else continue;
452
+ if (message.get("error")) |err| {
453
+ const text = try std.fmt.allocPrint(arena, "{f}", .{std.json.fmt(err, .{})});
454
+ switch (reply_id) {
455
+ 1 => runner.fail("initialize_failed", "initialize_failed", text),
456
+ 2 => runner.fail("session_failed", "session_failed", text),
457
+ else => runner.fail("prompt_failed", "prompt_failed", text),
458
+ }
459
+ }
460
+ const result = objectField(message, "result") orelse continue;
461
+ switch (reply_id) {
462
+ 1 => try runner.send(.{ .jsonrpc = "2.0", .id = 2, .method = "session/new", .params = .{ .cwd = workspace, .mcpServers = [_]u32{} } }),
463
+ 2 => {
464
+ const session = stringField(result, "sessionId") orelse runner.fail("session_failed", "session_failed", "session/new returned no sessionId");
465
+ runner.session_id = try arena.dupe(u8, session);
466
+ shared.emit(.{ .type = "fx.started", .sessionId = runner.session_id });
467
+ try runner.send(.{ .jsonrpc = "2.0", .id = 3, .method = "session/prompt", .params = .{
468
+ .sessionId = runner.session_id.?,
469
+ .prompt = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = prompt }},
470
+ } });
471
+ },
472
+ 3 => {
473
+ const stop = stringField(result, "stopReason") orelse "unknown";
474
+ if (!std.mem.eql(u8, stop, "end_turn")) {
475
+ const text = try std.fmt.allocPrint(arena, "the turn ended: {s}", .{stop});
476
+ runner.fail(if (std.mem.eql(u8, stop, "cancelled")) "aborted" else try arena.dupe(u8, stop), try arena.dupe(u8, stop), text);
477
+ }
478
+ if (runner.usageOrNull() == null) {
479
+ // No request reached the relay with usage; fx's own totals
480
+ // are the fallback, without the cache split it cannot report.
481
+ if (objectField(result, "usage")) |reported| {
482
+ shared.mutex.lockUncancelable(io);
483
+ defer shared.mutex.unlock(io);
484
+ const input = reported.get("inputTokens");
485
+ const output = reported.get("outputTokens");
486
+ shared.usage.inputTokens = if (input) |v| if (v == .integer and v.integer >= 0) @intCast(v.integer) else 0 else 0;
487
+ shared.usage.outputTokens = if (output) |v| if (v == .integer and v.integer >= 0) @intCast(v.integer) else 0 else 0;
488
+ shared.saw_usage = true;
489
+ }
490
+ }
491
+ runner.complete();
492
+ },
493
+ else => {},
494
+ }
495
+ }
496
+ }
497
+
498
+ test {
499
+ _ = home;
500
+ _ = permissions;
501
+ _ = relay_mod;
502
+ _ = @import("usage.zig");
503
+ }
@@ -0,0 +1,51 @@
1
+ //! The file-effect boundary for an fx worker, decided per ACP permission
2
+ //! request. fx in `ask` mode asks its client before every shell command and
3
+ //! file mutation, so the contract's `sandbox` value becomes a decision this
4
+ //! module makes, not a mode fx is trusted to enforce.
5
+
6
+ const std = @import("std");
7
+
8
+ pub const Verdict = struct {
9
+ allow: bool,
10
+ reason: ?[]const u8 = null,
11
+ };
12
+
13
+ /// Shell commands run in every mode, as they do under dsh's
14
+ /// `workspace-write`: the worktree, not a shell parser, bounds them. File
15
+ /// mutations are decided by path. `reason` is allocated in `arena` when set.
16
+ pub fn verdict(arena: std.mem.Allocator, sandbox: []const u8, workspace: []const u8, kind: []const u8, path: ?[]const u8) !Verdict {
17
+ if (std.mem.eql(u8, sandbox, "danger-full-access")) return .{ .allow = true };
18
+ for ([_][]const u8{ "read", "search", "think", "execute" }) |free| {
19
+ if (std.mem.eql(u8, kind, free)) return .{ .allow = true };
20
+ }
21
+ const mutates = std.mem.eql(u8, kind, "edit") or std.mem.eql(u8, kind, "delete") or std.mem.eql(u8, kind, "move");
22
+ if (!mutates) return .{ .allow = false, .reason = try std.fmt.allocPrint(arena, "{s} tools need danger-full-access", .{kind}) };
23
+ if (std.mem.eql(u8, sandbox, "read-only")) return .{ .allow = false, .reason = "the sandbox is read-only" };
24
+ const named = path orelse return .{ .allow = false, .reason = "a file mutation named no path" };
25
+ if (named.len == 0) return .{ .allow = false, .reason = "a file mutation named no path" };
26
+ const resolved = try std.fs.path.resolve(arena, &.{ workspace, named });
27
+ const inside = resolved.len > workspace.len and
28
+ std.mem.startsWith(u8, resolved, workspace) and
29
+ resolved[workspace.len] == std.fs.path.sep;
30
+ if (!inside) return .{ .allow = false, .reason = try std.fmt.allocPrint(arena, "{s} is outside the workspace", .{named}) };
31
+ return .{ .allow = true };
32
+ }
33
+
34
+ test "file mutations are bounded by path and commands run" {
35
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
36
+ defer arena_state.deinit();
37
+ const arena = arena_state.allocator();
38
+ const ws = "/work/tree";
39
+ try std.testing.expect((try verdict(arena, "workspace-write", ws, "edit", "src/a.mjs")).allow);
40
+ try std.testing.expect((try verdict(arena, "workspace-write", ws, "edit", "/work/tree/src/a.mjs")).allow);
41
+ try std.testing.expect(!(try verdict(arena, "workspace-write", ws, "edit", "../escape.txt")).allow);
42
+ try std.testing.expect(!(try verdict(arena, "workspace-write", ws, "edit", "/work/treehouse/a")).allow);
43
+ try std.testing.expect(!(try verdict(arena, "workspace-write", ws, "edit", "/etc/hosts")).allow);
44
+ try std.testing.expect(!(try verdict(arena, "workspace-write", ws, "edit", null)).allow);
45
+ try std.testing.expect((try verdict(arena, "workspace-write", ws, "execute", null)).allow);
46
+ try std.testing.expect(!(try verdict(arena, "workspace-write", ws, "fetch", null)).allow);
47
+ try std.testing.expect(!(try verdict(arena, "read-only", ws, "edit", "src/a.mjs")).allow);
48
+ try std.testing.expect((try verdict(arena, "danger-full-access", ws, "edit", "/etc/hosts")).allow);
49
+ const outside = try verdict(arena, "workspace-write", ws, "edit", "../escape.txt");
50
+ try std.testing.expectEqualStrings("../escape.txt is outside the workspace", outside.reason.?);
51
+ }