faberun 0.26.2 → 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,124 @@
1
+ /**
2
+ * The fx harness adapter: capabilities, the command that starts Faberun's own
3
+ * fx client (`runner.mjs`), and the envelope folded from its transcript. The
4
+ * client, not the `fx` binary, is what the gate spawns.
5
+ */
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { parseVersion } from "../protocol.mjs";
11
+ import { normalizeRunnerTranscript, runnerEnvironmentOverlay, withSchema } from "../runner-transcript.mjs";
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+
15
+ /**
16
+ * fx has no one-shot surface that streams a turn or reports its cache split,
17
+ * so the adapter's real provider is this client: it speaks `fx acp`, answers
18
+ * fx's permission requests, and relays the provider traffic through a
19
+ * loopback meter. The `fx` binary named by `executable()` is the harness the
20
+ * client drives, and probing it is what preflight means by "the provider
21
+ * binary exists".
22
+ */
23
+ const RUNNER = join(HERE, "runner.mjs");
24
+
25
+ /**
26
+ * The same client in Zig (`native/`), built by `zig build` in that directory.
27
+ * Measured 2026-09-24 over three parseDuration turns: 15-16 MB resident
28
+ * against 83 MB for `runner.mjs`, with the same transcript. Until a release
29
+ * ships the binary, a checkout that has not built it runs `runner.mjs`.
30
+ */
31
+ const NATIVE_RUNNER = join(HERE, "native", "zig-out", "bin", process.platform === "win32" ? "faberun-fx-runner.exe" : "faberun-fx-runner");
32
+
33
+ /**
34
+ * Measured 2026-09-24 on the parseDuration fixture on one macOS machine: this
35
+ * runner peaked at 83 MB resident and `fx acp` at 9 MB, against 310-370 MB for
36
+ * `dsh --profile headless` alone, with the same model and a passing suite. The
37
+ * harness exists for parallel campaigns on DeepSeek, where that difference is
38
+ * the worker count a machine can hold.
39
+ *
40
+ * @type {import("../index.mjs").HarnessAdapter}
41
+ */
42
+ export const fxHarness = {
43
+ capabilities: {
44
+ // The schema rides in the prompt and the verdict is extracted from the
45
+ // final message, as dsh does; nothing in the wire enforces it.
46
+ structuredOutput: true,
47
+ promptTransport: "stdin",
48
+ // `runtime.sandbox` is decided per ACP permission request by
49
+ // `permissions.mjs`: file mutations by path, shell commands allowed.
50
+ sandbox: true,
51
+ permissions: false,
52
+ // `fx acp` can resume a session, but the runner builds a throwaway HOME
53
+ // per turn and fx stores sessions there; no id outlives the turn.
54
+ continuation: false,
55
+ tokenBudget: false,
56
+ costBudget: false,
57
+ usage: true,
58
+ cost: false,
59
+ toolPolicy: false,
60
+ // Measured 2026-09-24: one parseDuration turn streamed 7 `tool_call`
61
+ // updates as they happened; the runner forwards each as `fx.tool`.
62
+ streamsOutput: true,
63
+ // Not measured for fx.
64
+ signalsProcesses: null,
65
+ },
66
+
67
+ permissionExecution: {
68
+ field: "sandbox",
69
+ executingModes: ["read-only", "workspace-write", "danger-full-access"],
70
+ defaultMode: "workspace-write",
71
+ readOnlyModes: ["read-only"],
72
+ },
73
+
74
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
75
+ executable(runtime) {
76
+ return process.env.FABERUN_FX_BIN ?? runtime.executable ?? "fx";
77
+ },
78
+
79
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]} */
80
+ versionArgs(runtime) {
81
+ return runtime.versionArgs ?? ["--version"];
82
+ },
83
+
84
+ parseVersion,
85
+
86
+ /**
87
+ * @param {import("../index.mjs").HarnessRuntime} runtime
88
+ * @param {string} prompt
89
+ * @param {import("../index.mjs").CommandOptions} options
90
+ * @returns {import("../index.mjs").HarnessCommand}
91
+ */
92
+ command(runtime, prompt, options = {}) {
93
+ const args = ["--fx", this.executable(runtime), "--model", runtime.model];
94
+ if (runtime.sandbox) args.push("--sandbox", runtime.sandbox);
95
+ const config = runtime.config ?? {};
96
+ if (config.provider === "codex") args.push("--provider", "codex");
97
+ if (typeof config.base_url === "string" && config.base_url) args.push("--base-url", config.base_url);
98
+ if (typeof config["api_key.env_key"] === "string" && config["api_key.env_key"]) args.push("--key-env", config["api_key.env_key"]);
99
+ if (typeof config.context_window === "number") args.push("--context-window", String(config.context_window));
100
+ if (typeof config.max_output_tokens === "number") args.push("--max-output-tokens", String(config.max_output_tokens));
101
+ const native = existsSync(NATIVE_RUNNER);
102
+ return {
103
+ executable: native ? NATIVE_RUNNER : process.execPath,
104
+ args: native ? args : [RUNNER, ...args],
105
+ promptTransport: "stdin",
106
+ input: withSchema(prompt, options.schema),
107
+ env: runnerEnvironmentOverlay(process.env),
108
+ };
109
+ },
110
+
111
+ /**
112
+ * @param {string} stdout
113
+ * @param {number|null} exitCode
114
+ * @param {string|null} signal
115
+ * @param {import("../index.mjs").NormalizeOptions} [options]
116
+ * @returns {import("../index.mjs").ProviderEnvelope}
117
+ */
118
+ normalize(stdout, exitCode, signal, options = {}) {
119
+ return normalizeRunnerTranscript("fx", stdout, exitCode, signal, options);
120
+ },
121
+ };
122
+
123
+ export const harness = fxHarness;
124
+ export default fxHarness;
@@ -0,0 +1,20 @@
1
+ //! Builds `faberun-fx-runner`, the native fx client the fx adapter spawns.
2
+ //! `zig build` installs it to `zig-out/bin/`; `zig build test` runs the unit
3
+ //! tests of every module.
4
+
5
+ const std = @import("std");
6
+
7
+ pub fn build(b: *std.Build) void {
8
+ const target = b.standardTargetOptions(.{});
9
+ // ReleaseSafe unless told otherwise: the runner ships to operators, and
10
+ // Debug is 4x the binary for no runtime check ReleaseSafe drops.
11
+ const optimize = b.option(std.builtin.OptimizeMode, "optimize", "Optimization mode (default ReleaseSafe)") orelse .ReleaseSafe;
12
+ const module = b.createModule(.{
13
+ .root_source_file = b.path("main.zig"),
14
+ .target = target,
15
+ .optimize = optimize,
16
+ });
17
+ b.installArtifact(b.addExecutable(.{ .name = "faberun-fx-runner", .root_module = module }));
18
+ const tests = b.addTest(.{ .root_module = module });
19
+ b.step("test", "Run the runner's unit tests").dependOn(&b.addRunArtifact(tests).step);
20
+ }
@@ -0,0 +1,7 @@
1
+ .{
2
+ .name = .faberun_fx_runner,
3
+ .version = "0.1.0",
4
+ .fingerprint = 0x899586834bb2a4a8,
5
+ .minimum_zig_version = "0.16.0",
6
+ .paths = .{ "build.zig", "build.zig.zon", "main.zig", "home.zig", "permissions.zig", "relay.zig", "usage.zig" },
7
+ }
@@ -0,0 +1,85 @@
1
+ //! The HOME an fx worker runs under. fx reads provider connections only from
2
+ //! `$HOME/.fx/settings.json` and has no flag or variable that names another
3
+ //! settings file, so pointing a worker at its own usage relay means giving it
4
+ //! its own HOME. The same boundary closes the packet: fx loads every skill it
5
+ //! finds under `~/.fx`, `~/.claude`, `~/.codex`, `~/.agents` and
6
+ //! `~/.config/opencode` (fx 0.0.11 `skill_runtime.zig`), none of which a closed
7
+ //! task packet asked for.
8
+ //!
9
+ //! Everything else in the operator's HOME is linked in, because the worker's
10
+ //! shell inherits this HOME: git identity, npm and toolchain caches, and
11
+ //! version managers keep resolving to the real files.
12
+
13
+ const std = @import("std");
14
+ const Io = std.Io;
15
+
16
+ /// Top-level HOME entries fx scans for skills or reads as its own profile.
17
+ const withheld = [_][]const u8{ ".fx", ".claude", ".codex", ".agents" };
18
+ /// Entries under `~/.config` fx scans for skills.
19
+ const withheld_config = [_][]const u8{"opencode"};
20
+
21
+ fn isWithheld(name: []const u8, list: []const []const u8) bool {
22
+ for (list) |entry| if (std.mem.eql(u8, entry, name)) return true;
23
+ return false;
24
+ }
25
+
26
+ /// Links every entry of `from` into `to` except `list` and `rebuilt`.
27
+ fn linkEntries(arena: std.mem.Allocator, io: Io, from: []const u8, to: []const u8, list: []const []const u8, rebuilt: ?[]const u8) !void {
28
+ var dir = Io.Dir.openDirAbsolute(io, from, .{ .iterate = true }) catch |err| switch (err) {
29
+ // An operator without `~/.config` has nothing to link from it.
30
+ error.FileNotFound => return,
31
+ else => return err,
32
+ };
33
+ defer dir.close(io);
34
+ var entries = dir.iterate();
35
+ while (try entries.next(io)) |entry| {
36
+ if (isWithheld(entry.name, list)) continue;
37
+ if (rebuilt) |name| if (std.mem.eql(u8, entry.name, name)) continue;
38
+ const target = try std.fs.path.join(arena, &.{ from, entry.name });
39
+ const link = try std.fs.path.join(arena, &.{ to, entry.name });
40
+ try Io.Dir.symLinkAbsolute(io, target, link, .{});
41
+ }
42
+ }
43
+
44
+ /// Fill `home`, an empty directory the caller owns and removes, with links to
45
+ /// `real_home` and the fx settings document `settings`.
46
+ pub fn prepare(arena: std.mem.Allocator, io: Io, real_home: []const u8, home: []const u8, settings: []const u8) !void {
47
+ try linkEntries(arena, io, real_home, home, &withheld, ".config");
48
+ const config = try std.fs.path.join(arena, &.{ home, ".config" });
49
+ try Io.Dir.cwd().createDirPath(io, config);
50
+ try linkEntries(arena, io, try std.fs.path.join(arena, &.{ real_home, ".config" }), config, &withheld_config, null);
51
+ // fx on Linux refuses a profile directory others can read
52
+ // (`private_state_permissions_unsupported`); macOS did not check.
53
+ const fx_dir = try std.fs.path.join(arena, &.{ home, ".fx" });
54
+ _ = try Io.Dir.cwd().createDirPathStatus(io, fx_dir, .fromMode(0o700));
55
+ var dir = try Io.Dir.openDirAbsolute(io, fx_dir, .{});
56
+ defer dir.close(io);
57
+ try dir.writeFile(io, .{ .sub_path = "settings.json", .data = settings, .flags = .{ .permissions = .fromMode(0o600) } });
58
+ }
59
+
60
+ test "the worker HOME withholds every skill root and links the rest" {
61
+ const io = std.testing.io;
62
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
63
+ defer arena_state.deinit();
64
+ const arena = arena_state.allocator();
65
+ var tmp = std.testing.tmpDir(.{});
66
+ defer tmp.cleanup();
67
+ var root_buf: [4096]u8 = undefined;
68
+ const root = root_buf[0..try tmp.dir.realPath(io, &root_buf)];
69
+ for ([_][]const u8{ "real/.fx", "real/.claude", "real/.codex", "real/.agents", "real/.npm", "real/.config/opencode", "real/.config/git", "home" }) |sub| {
70
+ try tmp.dir.createDirPath(io, sub);
71
+ }
72
+ const real = try std.fs.path.join(arena, &.{ root, "real" });
73
+ const home = try std.fs.path.join(arena, &.{ root, "home" });
74
+ try prepare(arena, io, real, home, "{\"provider\":\"faberun\"}\n");
75
+ for ([_][]const u8{ "home/.claude", "home/.codex", "home/.agents", "home/.config/opencode" }) |sub| {
76
+ try std.testing.expectError(error.FileNotFound, tmp.dir.access(io, sub, .{}));
77
+ }
78
+ var link_buf: [4096]u8 = undefined;
79
+ const npm = link_buf[0..try tmp.dir.readLink(io, "home/.npm", &link_buf)];
80
+ try std.testing.expectEqualStrings(try std.fs.path.join(arena, &.{ real, ".npm" }), npm);
81
+ const git = link_buf[0..try tmp.dir.readLink(io, "home/.config/git", &link_buf)];
82
+ try std.testing.expectEqualStrings(try std.fs.path.join(arena, &.{ real, ".config", "git" }), git);
83
+ const settings = try tmp.dir.readFileAlloc(io, "home/.fx/settings.json", arena, .unlimited);
84
+ try std.testing.expectEqualStrings("{\"provider\":\"faberun\"}\n", settings);
85
+ }