summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config_file.zig977
-rw-r--r--src/extension_loader.zig223
-rw-r--r--src/glob.zig121
-rw-r--r--src/lua_runtime.zig32
-rw-r--r--src/luarocks_runtime.zig66
-rw-r--r--src/main.zig196
-rw-r--r--src/models_toml.zig375
-rw-r--r--src/panto_home.zig4
-rw-r--r--src/subcommand.zig14
9 files changed, 1753 insertions, 255 deletions
diff --git a/src/config_file.zig b/src/config_file.zig
new file mode 100644
index 0000000..533eb48
--- /dev/null
+++ b/src/config_file.zig
@@ -0,0 +1,977 @@
+//! Layered `config.toml` loader for the panto CLI.
+//!
+//! Four files are read and merged, lowest precedence first:
+//!
+//! 1. base — `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml`
+//! 2. user — `${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml`
+//! 3. project — `./.panto/config.toml`
+//! 4. local — `./.panto/config.local.toml`
+//!
+//! The `local` layer is intended to be git-ignored: it lets an individual
+//! developer apply more-opinionated overrides on top of the team-shared
+//! `project` layer without committing them.
+//!
+//! Merge semantics: **tables merge recursively; scalars and arrays from a
+//! higher-precedence layer overwrite wholesale.** A project file that sets
+//! `tools.deny = [...]` replaces the array entirely — it does not append to
+//! a user-level `tools.deny`. Tables (notably `providers`) accumulate: a
+//! provider defined only at the base layer survives even when the project
+//! layer adds a different provider.
+//!
+//! After merge, the document is resolved into a `Config`:
+//!
+//! - Every `providers.<name>` becomes a `Provider`. Its API key is taken
+//! from `api_key` if present, else from the environment variable named
+//! by `api_key_env_var`. A provider whose only key source is an absent
+//! env var is **silently dropped** — this is what lets the base layer
+//! ship default OpenAI/Anthropic providers that simply don't appear when
+//! the user hasn't exported the corresponding key.
+//! - `defaults.model` is parsed as `<provider_name>:<model_alias>`.
+//! - `tools` / `extensions` allow- and deny-lists are captured verbatim
+//! (as glob pattern lists). A pattern appearing in both allow and deny
+//! for the same kind is a hard error.
+//!
+//! Model *aliases* (the `<model_alias>` half of a model reference) are
+//! resolved separately against `models.toml` — this module only validates
+//! that the reference names a known provider.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+const toml = @import("toml");
+const panto = @import("panto");
+
+const glob = @import("glob.zig");
+const models_toml = @import("models_toml.zig");
+
+// Document-building helpers live in the toml library's `value` module and
+// are not re-exported at its root. `tableIterator` *is* re-exported, so we
+// use `toml.tableIterator` directly but reach through `value_mod` for the
+// constructors/mutators.
+const tvalue = toml.value_mod;
+
+pub const APIStyle = panto.config.APIStyle;
+
+// ===========================================================================
+// Resolved config model
+// ===========================================================================
+
+/// One fully-resolved provider. The API key has already been read (from the
+/// inline value or the named env var); a provider that never resolved a key
+/// is absent from `Config.providers` entirely.
+pub const Provider = struct {
+ /// The user-chosen provider name (the `providers.<name>` key). This is
+ /// the name used on the left of a `<provider>:<model>` reference.
+ name: []const u8,
+ style: APIStyle,
+ base_url: []const u8,
+ api_key: []const u8,
+
+ pub fn deinit(self: Provider, alloc: Allocator) void {
+ alloc.free(self.name);
+ alloc.free(self.base_url);
+ alloc.free(self.api_key);
+ }
+};
+
+/// A parsed `<provider>:<model>` reference. Both halves borrow from the
+/// owning `Config` (the `default_model` storage); do not free separately.
+pub const ModelRef = struct {
+ provider: []const u8,
+ model: []const u8,
+};
+
+/// Tool/extension availability policy. Empty `allow` means "allow all"
+/// (subject to `deny`). A name is permitted when it matches at least one
+/// allow pattern (or allow is empty) AND matches no deny pattern.
+pub const Policy = struct {
+ allow: [][]const u8,
+ deny: [][]const u8,
+
+ pub fn deinit(self: Policy, alloc: Allocator) void {
+ for (self.allow) |p| alloc.free(p);
+ alloc.free(self.allow);
+ for (self.deny) |p| alloc.free(p);
+ alloc.free(self.deny);
+ }
+
+ /// True if `name` is permitted under this policy.
+ pub fn permits(self: Policy, name: []const u8) bool {
+ for (self.deny) |pat| {
+ if (glob.match(pat, name)) return false;
+ }
+ if (self.allow.len == 0) return true;
+ for (self.allow) |pat| {
+ if (glob.match(pat, name)) return true;
+ }
+ return false;
+ }
+};
+
+/// Build a `panto.config.ProviderConfig` for a chosen `<provider>:<alias>` model
+/// reference, combining the resolved provider (transport/auth) with the
+/// model definition from `models.toml` (wire name + knobs).
+///
+/// `ref` selects the provider and alias. `defs` supplies per-model knobs;
+/// a missing alias falls back to using the alias verbatim as the wire
+/// model name with default knobs. Returns the assembled `Config` plus the
+/// wire model id (borrowed from `defs`/`ref` — valid as long as both
+/// outlive the returned config's use). The `panto.config.ProviderConfig` itself
+/// borrows the provider/model strings; the caller must keep `cfg` (the
+/// `Config`) and `defs` alive for its lifetime.
+pub fn buildProviderConfig(
+ cfg: *const Config,
+ defs: *const models_toml.ModelRegistry,
+ ref: ModelRef,
+) ResolveError!panto.config.ProviderConfig {
+ const prov = cfg.provider(ref.provider) orelse return error.UnknownProvider;
+
+ const def_opt = defs.get(ref.provider, ref.model);
+ const wire_model: []const u8 = if (def_opt) |d| d.model else ref.model;
+ const reasoning: panto.config.ReasoningEffort = if (def_opt) |d| d.reasoning else .default;
+
+ switch (prov.style) {
+ .openai_chat => return .{ .openai_chat = .{
+ .api_key = prov.api_key,
+ .base_url = prov.base_url,
+ .model = wire_model,
+ .reasoning = reasoning,
+ } },
+ .anthropic_messages => return .{ .anthropic_messages = .{
+ .api_key = prov.api_key,
+ .base_url = prov.base_url,
+ .model = wire_model,
+ .api_version = if (def_opt) |d| (d.api_version orelse "2023-06-01") else "2023-06-01",
+ .max_tokens = if (def_opt) |d| (d.max_tokens orelse 4096) else 4096,
+ } },
+ }
+}
+
+pub const Config = struct {
+ allocator: Allocator,
+ providers: []Provider,
+ /// `defaults.model` storage, owned. `default_model_ref` slices into it.
+ default_model: ?[]const u8,
+ default_model_ref: ?ModelRef,
+ tools: Policy,
+ extensions: Policy,
+
+ pub fn deinit(self: *Config) void {
+ for (self.providers) |p| p.deinit(self.allocator);
+ self.allocator.free(self.providers);
+ if (self.default_model) |m| self.allocator.free(m);
+ self.tools.deinit(self.allocator);
+ self.extensions.deinit(self.allocator);
+ }
+
+ /// Look up a resolved provider by name. Returns null if absent (e.g.
+ /// dropped because its env var wasn't set).
+ pub fn provider(self: *const Config, name: []const u8) ?*const Provider {
+ for (self.providers) |*p| {
+ if (std.mem.eql(u8, p.name, name)) return p;
+ }
+ return null;
+ }
+
+ /// Choose the model reference to start with. Precedence:
+ /// 1. an explicit `model_override` (e.g. a future `--model` flag),
+ /// 2. `defaults.model` from config,
+ /// 3. if exactly one provider resolved AND it has exactly one model
+ /// alias in `defs`, use that,
+ /// 4. otherwise error (`NoModelSelected`).
+ ///
+ /// The returned ref borrows from `self`/`defs`/`model_override`.
+ pub fn selectModel(
+ self: *const Config,
+ defs: *const models_toml.ModelRegistry,
+ model_override: ?[]const u8,
+ ) ResolveError!ModelRef {
+ if (model_override) |m| {
+ return parseModelRef(m) catch return error.NoModelSelected;
+ }
+ if (self.default_model_ref) |ref| return ref;
+
+ // Single-provider, single-alias convenience.
+ if (self.providers.len == 1) {
+ const only = self.providers[0].name;
+ var found: ?ModelRef = null;
+ for (defs.entries.items) |d| {
+ if (std.mem.eql(u8, d.provider, only)) {
+ if (found != null) {
+ found = null; // more than one alias; ambiguous.
+ break;
+ }
+ found = .{ .provider = d.provider, .model = d.alias };
+ }
+ }
+ if (found) |ref| return ref;
+ }
+ return error.NoModelSelected;
+ }
+};
+
+pub const Error = error{
+ NoHomeDirectory,
+ InvalidConfigToml,
+ InvalidProvider,
+ InvalidModelRef,
+ UnknownDefaultProvider,
+ PolicyConflict,
+} || Allocator.Error || FileError;
+
+pub const ResolveError = error{
+ NoModelSelected,
+ UnknownProvider,
+};
+
+/// The filesystem errors that can surface while reading a config layer.
+/// `FileNotFound` is handled by the caller (skip the layer); any other
+/// I/O failure is collapsed to `error.ConfigReadFailed` so this module
+/// keeps a small, stable error set.
+pub const FileError = Io.Cancelable || error{ FileNotFound, ConfigReadFailed };
+
+// ===========================================================================
+// Public entry points
+// ===========================================================================
+
+/// Resolve and merge the four config layers from their standard paths,
+/// then build a `Config`. Missing files are skipped silently. `cwd` is the
+/// project root (used for the `./.panto/config.toml` and
+/// `./.panto/config.local.toml` layers).
+pub fn load(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ cwd: []const u8,
+) Error!Config {
+ const paths = try layerPaths(allocator, environ_map, cwd);
+ defer paths.deinit(allocator);
+
+ return loadFromPaths(allocator, io, environ_map, &.{
+ paths.base,
+ paths.user,
+ paths.project,
+ paths.local,
+ });
+}
+
+const LayerPaths = struct {
+ base: []u8,
+ user: []u8,
+ project: []u8,
+ local: []u8,
+
+ fn deinit(self: LayerPaths, alloc: Allocator) void {
+ alloc.free(self.base);
+ alloc.free(self.user);
+ alloc.free(self.project);
+ alloc.free(self.local);
+ }
+};
+
+fn layerPaths(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ cwd: []const u8,
+) Error!LayerPaths {
+ const base = try baseConfigPath(allocator, environ_map);
+ errdefer allocator.free(base);
+ const user = try userConfigPath(allocator, environ_map);
+ errdefer allocator.free(user);
+ const project = try std.fs.path.join(allocator, &.{ cwd, ".panto", "config.toml" });
+ errdefer allocator.free(project);
+ const local = try std.fs.path.join(allocator, &.{ cwd, ".panto", "config.local.toml" });
+ return .{ .base = base, .user = user, .project = project, .local = local };
+}
+
+/// `$PANTO_HOME/config.toml`, falling back to
+/// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml`.
+///
+/// `PANTO_HOME` is checked first so this always agrees with where the
+/// bootstrap stages the default base config (see `panto_home.resolveHome`).
+pub fn baseConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 {
+ if (environ_map.get("PANTO_HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, "config.toml" });
+ }
+ if (environ_map.get("XDG_DATA_HOME")) |xdg| {
+ return try std.fs.path.join(allocator, &.{ xdg, "panto", "config.toml" });
+ }
+ if (environ_map.get("HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "config.toml" });
+ }
+ return error.NoHomeDirectory;
+}
+
+/// `${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml`.
+pub fn userConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 {
+ if (environ_map.get("XDG_CONFIG_HOME")) |xdg| {
+ return try std.fs.path.join(allocator, &.{ xdg, "panto", "config.toml" });
+ }
+ if (environ_map.get("HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, ".config", "panto", "config.toml" });
+ }
+ return error.NoHomeDirectory;
+}
+
+/// Load from explicit layer paths, lowest precedence first. Useful for
+/// tests. Missing files are skipped.
+pub fn loadFromPaths(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ paths: []const []const u8,
+) Error!Config {
+ // The merged document is built into its own arena-backed Document so we
+ // never have to reason about which source layer a given Value came from.
+ const merged = try tvalue.createDocument(allocator);
+ defer merged.deinit();
+
+ for (paths) |path| {
+ const bytes = readFileAlloc(allocator, io, path) catch |err| switch (err) {
+ error.FileNotFound => continue,
+ else => return err,
+ };
+ defer allocator.free(bytes);
+
+ const doc = parseDoc(allocator, bytes) catch return error.InvalidConfigToml;
+ defer doc.deinit();
+ try mergeTable(merged.allocator(), merged.root, doc.root);
+ }
+
+ return resolve(allocator, environ_map, merged.root);
+}
+
+// ===========================================================================
+// Merge
+// ===========================================================================
+
+/// Deep-merge `src` table into `dst` table (both must be `.table`).
+/// Tables recurse; everything else (scalars, arrays) overwrites. Values are
+/// deep-copied into `alloc` (the merged document's arena) so they outlive
+/// the source document.
+fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void {
+ std.debug.assert(dst.* == .table);
+ if (src.* != .table) return;
+
+ var it = toml.tableIterator(src);
+ while (it.next()) |entry| {
+ const existing = dst.get(entry.key);
+ if (existing != null and existing.?.* == .table and entry.value.* == .table) {
+ // Both sides are tables — recurse to accumulate keys.
+ try mergeTable(alloc, @constCast(existing.?), entry.value);
+ } else {
+ // Overwrite (or insert) with a deep copy of the source value.
+ const copy = try cloneValue(alloc, entry.value);
+ const key_copy = try alloc.dupe(u8, entry.key);
+ try tvalue.tableSet(alloc, dst, key_copy, copy);
+ }
+ }
+}
+
+fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value {
+ const out = try alloc.create(toml.Value);
+ switch (src.*) {
+ .table => {
+ out.* = .{ .table = .{} };
+ var it = toml.tableIterator(src);
+ while (it.next()) |entry| {
+ const child = try cloneValue(alloc, entry.value);
+ const key_copy = try alloc.dupe(u8, entry.key);
+ try tvalue.tableSet(alloc, out, key_copy, child);
+ }
+ },
+ .array => |*a| {
+ out.* = .{ .array = .{} };
+ for (a.items.items) |*item| {
+ const child = try cloneValue(alloc, item);
+ try tvalue.arrayAppend(alloc, out, child.*);
+ }
+ },
+ .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) },
+ else => out.* = src.*,
+ }
+ return out;
+}
+
+// ===========================================================================
+// Resolve
+// ===========================================================================
+
+fn resolve(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ root: *const toml.Value,
+) Error!Config {
+ var providers: std.ArrayList(Provider) = .empty;
+ errdefer {
+ for (providers.items) |p| p.deinit(allocator);
+ providers.deinit(allocator);
+ }
+
+ if (root.get("providers")) |providers_tbl| {
+ if (providers_tbl.* == .table) {
+ var it = toml.tableIterator(providers_tbl);
+ while (it.next()) |entry| {
+ const maybe = try resolveProvider(allocator, environ_map, entry.key, entry.value);
+ if (maybe) |p| try providers.append(allocator, p);
+ }
+ }
+ }
+
+ // Tool / extension policies.
+ var tools = try resolvePolicy(allocator, root, "tools");
+ errdefer tools.deinit(allocator);
+ var extensions = try resolvePolicy(allocator, root, "extensions");
+ errdefer extensions.deinit(allocator);
+
+ // Default model.
+ var default_model: ?[]const u8 = null;
+ errdefer if (default_model) |m| allocator.free(m);
+ var default_model_ref: ?ModelRef = null;
+ if (root.get("defaults")) |defaults_tbl| {
+ if (defaults_tbl.get("model")) |model_v| {
+ if (model_v.* == .string) {
+ default_model = try allocator.dupe(u8, model_v.string);
+ default_model_ref = try parseModelRef(default_model.?);
+ }
+ }
+ }
+
+ const providers_slice = try providers.toOwnedSlice(allocator);
+ errdefer {
+ for (providers_slice) |p| p.deinit(allocator);
+ allocator.free(providers_slice);
+ }
+
+ // Validate the default model names a provider that actually resolved.
+ if (default_model_ref) |ref| {
+ var found = false;
+ for (providers_slice) |p| {
+ if (std.mem.eql(u8, p.name, ref.provider)) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) return error.UnknownDefaultProvider;
+ }
+
+ return .{
+ .allocator = allocator,
+ .providers = providers_slice,
+ .default_model = default_model,
+ .default_model_ref = default_model_ref,
+ .tools = tools,
+ .extensions = extensions,
+ };
+}
+
+/// Resolve one `providers.<name>` entry. Returns null (provider dropped) if
+/// the only key source is an env var that isn't set.
+fn resolveProvider(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ name: []const u8,
+ val: *const toml.Value,
+) Error!?Provider {
+ if (val.* != .table) return error.InvalidProvider;
+
+ const style_str = (val.get("style") orelse return error.InvalidProvider).asString() orelse
+ return error.InvalidProvider;
+ const style = std.meta.stringToEnum(APIStyle, style_str) orelse return error.InvalidProvider;
+
+ const base_url = (val.get("base_url") orelse return error.InvalidProvider).asString() orelse
+ return error.InvalidProvider;
+
+ // api_key wins over api_key_env_var.
+ const api_key: []const u8 = blk: {
+ if (val.get("api_key")) |k| {
+ if (k.asString()) |s| break :blk s;
+ }
+ if (val.get("api_key_env_var")) |ev| {
+ if (ev.asString()) |env_name| {
+ if (environ_map.get(env_name)) |actual| break :blk actual;
+ }
+ }
+ // No usable key — silently omit this provider.
+ return null;
+ };
+
+ return .{
+ .name = try allocator.dupe(u8, name),
+ .style = style,
+ .base_url = try allocator.dupe(u8, base_url),
+ .api_key = try allocator.dupe(u8, api_key),
+ };
+}
+
+fn resolvePolicy(allocator: Allocator, root: *const toml.Value, key: []const u8) Error!Policy {
+ var allow: [][]const u8 = &.{};
+ var deny: [][]const u8 = &.{};
+ errdefer {
+ for (allow) |p| allocator.free(p);
+ allocator.free(allow);
+ for (deny) |p| allocator.free(p);
+ allocator.free(deny);
+ }
+
+ if (root.get(key)) |tbl| {
+ if (tbl.* == .table) {
+ allow = try stringArray(allocator, tbl, "allow");
+ deny = try stringArray(allocator, tbl, "deny");
+ }
+ }
+
+ // A pattern present in both allow and deny is contradictory.
+ for (allow) |a| {
+ for (deny) |d| {
+ if (std.mem.eql(u8, a, d)) return error.PolicyConflict;
+ }
+ }
+
+ return .{ .allow = allow, .deny = deny };
+}
+
+fn stringArray(allocator: Allocator, tbl: *const toml.Value, key: []const u8) Error![][]const u8 {
+ const arr_v = tbl.get(key) orelse return &.{};
+ if (arr_v.* != .array) return &.{};
+
+ var list: std.ArrayList([]const u8) = .empty;
+ errdefer {
+ for (list.items) |s| allocator.free(s);
+ list.deinit(allocator);
+ }
+ for (arr_v.array.items.items) |*item| {
+ if (item.* == .string) {
+ try list.append(allocator, try allocator.dupe(u8, item.string));
+ }
+ }
+ return try list.toOwnedSlice(allocator);
+}
+
+/// Parse `<provider>:<model>`. Both halves must be non-empty and there must
+/// be exactly one separating colon.
+pub fn parseModelRef(ref: []const u8) Error!ModelRef {
+ const colon = std.mem.indexOfScalar(u8, ref, ':') orelse return error.InvalidModelRef;
+ const provider = ref[0..colon];
+ const model = ref[colon + 1 ..];
+ if (provider.len == 0 or model.len == 0) return error.InvalidModelRef;
+ if (std.mem.indexOfScalar(u8, model, ':') != null) return error.InvalidModelRef;
+ return .{ .provider = provider, .model = model };
+}
+
+// ===========================================================================
+// File / parse helpers
+// ===========================================================================
+
+fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) (Allocator.Error || FileError)![]u8 {
+ const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
+ error.FileNotFound => return error.FileNotFound,
+ error.Canceled => return error.Canceled,
+ else => return error.ConfigReadFailed,
+ };
+ defer file.close(io);
+ const len = file.length(io) catch return error.ConfigReadFailed;
+ const bytes = try allocator.alloc(u8, @intCast(len));
+ errdefer allocator.free(bytes);
+ _ = file.readPositionalAll(io, bytes, 0) catch |err| switch (err) {
+ error.Canceled => return error.Canceled,
+ else => return error.ConfigReadFailed,
+ };
+ return bytes;
+}
+
+fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document {
+ const result = toml.parseWithError(allocator, source, .{});
+ switch (result) {
+ .err => |e| {
+ if (!@import("builtin").is_test) {
+ std.log.err(
+ "config.toml: parse error at line {d}, column {d}: {s}",
+ .{ e.line, e.column, e.message },
+ );
+ }
+ return error.InvalidConfigToml;
+ },
+ .ok => |doc| return doc,
+ }
+}
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+
+fn emptyEnv(a: Allocator) std.process.Environ.Map {
+ return std.process.Environ.Map.init(a);
+}
+
+test "parseModelRef: splits provider and model" {
+ const ref = try parseModelRef("anthropic:claude-sonnet-4-6");
+ try testing.expectEqualStrings("anthropic", ref.provider);
+ try testing.expectEqualStrings("claude-sonnet-4-6", ref.model);
+}
+
+test "parseModelRef: rejects malformed refs" {
+ try testing.expectError(error.InvalidModelRef, parseModelRef("no-colon"));
+ try testing.expectError(error.InvalidModelRef, parseModelRef(":model"));
+ try testing.expectError(error.InvalidModelRef, parseModelRef("provider:"));
+ try testing.expectError(error.InvalidModelRef, parseModelRef("a:b:c"));
+}
+
+test "Policy.permits: empty allow means allow-all minus deny" {
+ const a = testing.allocator;
+ var deny = try a.alloc([]const u8, 1);
+ deny[0] = try a.dupe(u8, "std.shell");
+ const policy: Policy = .{ .allow = &.{}, .deny = deny };
+ defer policy.deinit(a);
+
+ try testing.expect(policy.permits("std.read"));
+ try testing.expect(!policy.permits("std.shell"));
+}
+
+test "Policy.permits: allow gates everything not matched" {
+ const a = testing.allocator;
+ var allow = try a.alloc([]const u8, 1);
+ allow[0] = try a.dupe(u8, "std.*");
+ const policy: Policy = .{ .allow = allow, .deny = &.{} };
+ defer policy.deinit(a);
+
+ try testing.expect(policy.permits("std.read"));
+ try testing.expect(!policy.permits("other.read"));
+}
+
+test "resolve: env-backed provider is dropped when env var is absent" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+ try testing.expectEqual(@as(usize, 0), cfg.providers.len);
+}
+
+test "resolve: env-backed provider resolves when env var is set" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+ try testing.expectEqual(@as(usize, 1), cfg.providers.len);
+ const p = cfg.provider("anthropic").?;
+ try testing.expectEqual(APIStyle.anthropic_messages, p.style);
+ try testing.expectEqualStrings("sk-ant-xyz", p.api_key);
+}
+
+test "resolve: inline api_key wins over env var" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("KEY_FROM_ENV", "env-value");
+
+ const src =
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "inline-value"
+ \\api_key_env_var = "KEY_FROM_ENV"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+ const p = cfg.provider("openai").?;
+ try testing.expectEqualStrings("inline-value", p.api_key);
+}
+
+test "resolve: unknown default-model provider is an error" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[defaults]
+ \\model = "ghost:some-model"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root));
+}
+
+test "resolvePolicy: pattern in both allow and deny is a conflict" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[tools]
+ \\allow = ["std.*"]
+ \\deny = ["std.*"]
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ try testing.expectError(error.PolicyConflict, resolve(a, &env, doc.root));
+}
+
+test "loadFromPaths: layers merge with tables accumulating and scalars overriding" {
+ const a = testing.allocator;
+ const io = testing.io;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const tmp_n = try tmp.dir.realPath(io, &path_buf);
+ const tmp_path = path_buf[0..tmp_n];
+
+ // Base layer: defines anthropic provider + a default model.
+ const sys_path = try std.fs.path.join(a, &.{ tmp_path, "base.toml" });
+ defer a.free(sys_path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "base.toml", .data =
+ \\[defaults]
+ \\model = "anthropic:sonnet"
+ \\
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key = "sys-key"
+ \\
+ \\[tools]
+ \\allow = ["std.*"]
+ });
+
+ // Project layer: adds an openai provider (table accumulates), and
+ // overrides the default model (scalar overwrites).
+ const proj_path = try std.fs.path.join(a, &.{ tmp_path, "project.toml" });
+ defer a.free(proj_path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "project.toml", .data =
+ \\[defaults]
+ \\model = "openai:gpt"
+ \\
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "proj-key"
+ });
+
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ var cfg = try loadFromPaths(a, io, &env, &.{ sys_path, proj_path });
+ defer cfg.deinit();
+
+ // Both providers survive (table accumulation).
+ try testing.expectEqual(@as(usize, 2), cfg.providers.len);
+ try testing.expect(cfg.provider("anthropic") != null);
+ try testing.expect(cfg.provider("openai") != null);
+
+ // Default model overridden by the project layer.
+ try testing.expectEqualStrings("openai:gpt", cfg.default_model.?);
+ try testing.expectEqualStrings("openai", cfg.default_model_ref.?.provider);
+ try testing.expectEqualStrings("gpt", cfg.default_model_ref.?.model);
+
+ // tools.allow from the base layer is preserved (no project override).
+ try testing.expect(cfg.tools.permits("std.read"));
+}
+
+test "loadFromPaths: local layer overrides project layer" {
+ const a = testing.allocator;
+ const io = testing.io;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const tmp_n = try tmp.dir.realPath(io, &path_buf);
+ const tmp_path = path_buf[0..tmp_n];
+
+ // Project layer: shared default model + provider.
+ const proj_path = try std.fs.path.join(a, &.{ tmp_path, "project.toml" });
+ defer a.free(proj_path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "project.toml", .data =
+ \\[defaults]
+ \\model = "openai:gpt"
+ \\
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "proj-key"
+ });
+
+ // Local layer (git-ignored): a developer's more-opinionated override of
+ // the default model. Scalar overwrites; the provider table accumulates.
+ const local_path = try std.fs.path.join(a, &.{ tmp_path, "local.toml" });
+ defer a.free(local_path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "local.toml", .data =
+ \\[defaults]
+ \\model = "openai:gpt-local"
+ });
+
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ var cfg = try loadFromPaths(a, io, &env, &.{ proj_path, local_path });
+ defer cfg.deinit();
+
+ // Local layer wins for the default model.
+ try testing.expectEqualStrings("openai:gpt-local", cfg.default_model.?);
+ // Provider from the project layer survives (table accumulation).
+ try testing.expect(cfg.provider("openai") != null);
+}
+
+test "buildProviderConfig: anthropic ref pulls knobs from model defs" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("ANTHROPIC_API_KEY", "sk-ant");
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+ try models.entries.append(a, .{
+ .provider = try a.dupe(u8, "anthropic"),
+ .alias = try a.dupe(u8, "sonnet"),
+ .model = try a.dupe(u8, "claude-sonnet-4-20250514"),
+ .reasoning = .high,
+ .max_tokens = 8192,
+ .api_version = try a.dupe(u8, "2023-06-01"),
+ });
+
+ const ref = try parseModelRef("anthropic:sonnet");
+ const pc = try buildProviderConfig(&cfg, &models, ref);
+ try testing.expectEqual(APIStyle.anthropic_messages, pc.style());
+ try testing.expectEqualStrings("claude-sonnet-4-20250514", pc.anthropic_messages.model);
+ try testing.expectEqual(@as(u32, 8192), pc.anthropic_messages.max_tokens);
+ try testing.expectEqualStrings("sk-ant", pc.anthropic_messages.api_key);
+}
+
+test "buildProviderConfig: missing alias uses the alias verbatim as wire model" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "sk-test"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+
+ const ref = try parseModelRef("openai:gpt-4o");
+ const pc = try buildProviderConfig(&cfg, &models, ref);
+ try testing.expectEqual(APIStyle.openai_chat, pc.style());
+ try testing.expectEqualStrings("gpt-4o", pc.openai_chat.model);
+ try testing.expectEqual(panto.config.ReasoningEffort.default, pc.openai_chat.reasoning);
+}
+
+test "buildProviderConfig: unknown provider errors" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ const empty = try parseDoc(a, "");
+ defer empty.deinit();
+ var cfg = try resolve(a, &env, empty.root);
+ defer cfg.deinit();
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+ try testing.expectError(error.UnknownProvider, buildProviderConfig(&cfg, &models, .{ .provider = "ghost", .model = "x" }));
+}
+
+test "selectModel: default_model wins; single-provider fallback otherwise" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ // Config with one provider and a default model.
+ const src =
+ \\[defaults]
+ \\model = "openai:gpt"
+ \\
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "sk"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+
+ const ref = try cfg.selectModel(&models, null);
+ try testing.expectEqualStrings("openai", ref.provider);
+ try testing.expectEqualStrings("gpt", ref.model);
+
+ // Override beats default.
+ const ref2 = try cfg.selectModel(&models, "openai:other");
+ try testing.expectEqualStrings("other", ref2.model);
+}
+
+test "selectModel: no default and no providers errors" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ const empty = try parseDoc(a, "");
+ defer empty.deinit();
+ var cfg = try resolve(a, &env, empty.root);
+ defer cfg.deinit();
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+ try testing.expectError(error.NoModelSelected, cfg.selectModel(&models, null));
+}
+
+test "loadFromPaths: missing files are skipped" {
+ const a = testing.allocator;
+ const io = testing.io;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ var cfg = try loadFromPaths(a, io, &env, &.{
+ "/nonexistent/base.toml",
+ "/nonexistent/project.toml",
+ });
+ defer cfg.deinit();
+ try testing.expectEqual(@as(usize, 0), cfg.providers.len);
+ try testing.expect(cfg.default_model == null);
+}
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
index 2fd86ae..b84eba5 100644
--- a/src/extension_loader.zig
+++ b/src/extension_loader.zig
@@ -1,22 +1,22 @@
//! Extension and tool discovery: walk well-known directories, locate Lua
//! files, and load each into a long-lived `LuaRuntime`.
//!
-//! Two parallel namespaces are scanned, each at three scopes — system,
-//! user, and project. Project shadows user shadows system.
+//! Two parallel namespaces are scanned, each at three scopes — base,
+//! user, and project. Project shadows user shadows base.
//!
//! Extensions (full-featured; call `panto.register_tool` from a script
//! that may register many tools):
-//! 1. `$PANTO_HOME/agent/extensions/` ("system")
+//! 1. `$PANTO_HOME/agent/extensions/` ("base")
//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user")
//! 3. `./.panto/extensions/` ("project")
//!
//! Tools (ergonomic single-tool form; the script returns one table
//! shaped like the argument to `panto.register_tool`):
-//! 1. `$PANTO_HOME/agent/tools/` ("system")
+//! 1. `$PANTO_HOME/agent/tools/` ("base")
//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user")
//! 3. `./.panto/tools/` ("project")
//!
-//! The `system` layer is populated at bootstrap from files embedded
+//! The `base` layer is populated at bootstrap from files embedded
//! into the panto binary (see `build/gen_agent_embed.zig` and
//! `luarocks_runtime.stageAgentTree`). It is panto's "batteries" tier:
//! ships in the binary, but every entry is individually shadowable
@@ -33,7 +33,7 @@
//! Conflict rules:
//! - Within one directory, two entries with the same logical name
//! are an error.
-//! - Project shadows user shadows system *within the same kind*
+//! - Project shadows user shadows base *within the same kind*
//! (extension or tool). Extensions and tools live in distinct
//! namespaces for shadowing; the registered *tool names* still
//! share one global namespace across both, and collisions there
@@ -44,11 +44,32 @@
const std = @import("std");
const panto = @import("panto");
const lua_runtime = @import("lua_runtime.zig");
+const config_file = @import("config_file.zig");
const Allocator = std.mem.Allocator;
const Io = std.Io;
const LuaRuntime = lua_runtime.LuaRuntime;
+/// Availability policies for the two namespaces. Both are optional; a
+/// null policy permits everything. `extensions` gates extension/tool
+/// *entries* by their logical (file/dir) name before loading; `tools`
+/// gates the *registered tool names* (e.g. `std.read`) after loading.
+pub const Policies = struct {
+ extensions: ?*const config_file.Policy = null,
+ tools: ?*const config_file.Policy = null,
+};
+
+fn policyPermits(p: ?*const config_file.Policy, name: []const u8) bool {
+ const pol = p orelse return true;
+ return pol.permits(name);
+}
+
+/// Free-function form of `Policy.permits` taking the policy by pointer,
+/// matching the `fn(ctx, name) bool` shape `LuaRuntime.filterTools` wants.
+fn permitsByPtr(pol: *const config_file.Policy, name: []const u8) bool {
+ return pol.permits(name);
+}
+
/// A discovered extension or tool before loading. Owns its strings.
const Found = struct {
/// Logical name (basename without `.lua`, or directory name).
@@ -72,13 +93,13 @@ const Found = struct {
pub const Source = enum {
/// Staged at bootstrap from files embedded in the panto binary.
/// Lowest priority — shadowed by user and project.
- system,
+ base,
user,
project,
pub fn label(self: Source) []const u8 {
return switch (self) {
- .system => "system",
+ .base => "base",
.user => "user",
.project => "project",
};
@@ -101,9 +122,9 @@ pub const Kind = enum {
/// paths into `runtime`. Returns the number of registered tools added
/// to the runtime by this call.
///
-/// `system_agent_dir`, when non-null, is the path under which embedded
-/// system tools/extensions have been staged — typically
-/// `$PANTO_HOME/agent/`. Pass `null` to skip the system layer entirely
+/// `base_agent_dir`, when non-null, is the path under which embedded
+/// base tools/extensions have been staged — typically
+/// `$PANTO_HOME/agent/`. Pass `null` to skip the base layer entirely
/// (mostly useful for tests).
///
/// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The
@@ -112,15 +133,16 @@ pub fn discoverAndLoad(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
- system_agent_dir: ?[]const u8,
+ base_agent_dir: ?[]const u8,
runtime: *LuaRuntime,
+ policies: Policies,
) !usize {
- const sys_ext = if (system_agent_dir) |d|
+ const sys_ext = if (base_agent_dir) |d|
try std.fs.path.join(allocator, &.{ d, kindSubdir(.extension) })
else
null;
defer if (sys_ext) |d| allocator.free(d);
- const sys_tool = if (system_agent_dir) |d|
+ const sys_tool = if (base_agent_dir) |d|
try std.fs.path.join(allocator, &.{ d, kindSubdir(.tool) })
else
null;
@@ -141,27 +163,28 @@ pub fn discoverAndLoad(
io,
runtime,
.{
- .system_extensions = sys_ext,
+ .base_extensions = sys_ext,
.user_extensions = user_ext,
.project_extensions = project_ext,
- .system_tools = sys_tool,
+ .base_tools = sys_tool,
.user_tools = user_tool,
.project_tools = project_tool,
},
+ policies,
);
}
/// Set of search paths consumed by `loadFromDirs`. Any field may be
/// null; missing directories on disk are silently skipped.
///
-/// Scan order is system → user → project. `applyShadowing` keeps the
+/// Scan order is base → user → project. `applyShadowing` keeps the
/// *last* occurrence of each (kind, name), so project entries win,
-/// then user, then system.
+/// then user, then base.
pub const DirSet = struct {
- system_extensions: ?[]const u8 = null,
+ base_extensions: ?[]const u8 = null,
user_extensions: ?[]const u8 = null,
project_extensions: ?[]const u8 = null,
- system_tools: ?[]const u8 = null,
+ base_tools: ?[]const u8 = null,
user_tools: ?[]const u8 = null,
project_tools: ?[]const u8 = null,
};
@@ -173,6 +196,7 @@ pub fn loadFromDirs(
io: Io,
runtime: *LuaRuntime,
dirs: DirSet,
+ policies: Policies,
) !usize {
var found: std.array_list.Managed(Found) = .init(allocator);
defer {
@@ -180,15 +204,38 @@ pub fn loadFromDirs(
found.deinit();
}
- if (dirs.system_extensions) |d| try scanDir(allocator, io, d, .system, .extension, &found);
+ if (dirs.base_extensions) |d| try scanDir(allocator, io, d, .base, .extension, &found);
if (dirs.user_extensions) |d| try scanDir(allocator, io, d, .user, .extension, &found);
if (dirs.project_extensions) |d| try scanDir(allocator, io, d, .project, .extension, &found);
- if (dirs.system_tools) |d| try scanDir(allocator, io, d, .system, .tool, &found);
+ if (dirs.base_tools) |d| try scanDir(allocator, io, d, .base, .tool, &found);
if (dirs.user_tools) |d| try scanDir(allocator, io, d, .user, .tool, &found);
if (dirs.project_tools) |d| try scanDir(allocator, io, d, .project, .tool, &found);
try applyShadowing(allocator, &found);
+ // Gate *entries* by the extensions policy (matched on logical name).
+ // This drops whole scripts before they run — a denied extension
+ // never executes, never registers tools. The tools policy is
+ // applied post-load against registered tool names.
+ if (policies.extensions) |_| {
+ var keep: std.array_list.Managed(Found) = .init(allocator);
+ errdefer {
+ for (keep.items) |*f| f.deinit(allocator);
+ keep.deinit();
+ }
+ for (found.items) |*f| {
+ if (policyPermits(policies.extensions, f.name)) {
+ try keep.append(f.*);
+ } else {
+ std.log.debug("{s}: '{s}' denied by extensions policy", .{ f.kind.label(), f.name });
+ f.deinit(allocator);
+ }
+ }
+ found.clearRetainingCapacity();
+ try found.appendSlice(keep.items);
+ keep.deinit();
+ }
+
const before = runtime.toolCount();
for (found.items) |f| {
const load_result = switch (f.kind) {
@@ -214,6 +261,13 @@ pub fn loadFromDirs(
.{ f.kind.label(), f.name, f.source.label() },
);
}
+
+ // Apply the tools policy to *registered tool names* (e.g. `std.read`).
+ if (policies.tools) |pol| {
+ const dropped = runtime.filterTools(pol, permitsByPtr);
+ if (dropped > 0) std.log.debug("tools: {d} tool(s) removed by tools policy", .{dropped});
+ }
+
return runtime.toolCount() - before;
}
@@ -581,7 +635,7 @@ test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" {
const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
.user_extensions = user_path,
.project_extensions = proj_path,
- });
+ }, .{});
try testing.expectEqual(@as(usize, 1), n_tools);
// Invoke the tool through the source and verify the project handler ran.
@@ -623,7 +677,7 @@ test "loadFromDirs: tool-name collision between extensions errors" {
const result = loadFromDirs(testing.allocator, testing.io, rt, .{
.project_extensions = ext_path,
- });
+ }, .{});
try testing.expectError(error.DuplicateTool, result);
}
@@ -650,7 +704,7 @@ test "loadFromDirs: tools/ directory — single-file tool form" {
const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
.user_tools = tools_path,
- });
+ }, .{});
try testing.expectEqual(@as(usize, 1), n_tools);
var src = rt.toolSource();
@@ -690,7 +744,7 @@ test "loadFromDirs: tools/ directory — directory-style tool with sibling requi
const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
.project_tools = tools_path,
- });
+ }, .{});
try testing.expectEqual(@as(usize, 1), n_tools);
var src = rt.toolSource();
@@ -738,7 +792,7 @@ test "loadFromDirs: project tool shadows user tool of the same name" {
const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
.user_tools = user_path,
.project_tools = proj_path,
- });
+ }, .{});
try testing.expectEqual(@as(usize, 1), n_tools);
var src = rt.toolSource();
@@ -749,19 +803,19 @@ test "loadFromDirs: project tool shadows user tool of the same name" {
try testing.expectEqualStrings("PROJECT", results[0].ok);
}
-test "loadFromDirs: project tool shadows user shadows system" {
+test "loadFromDirs: project tool shadows user shadows base" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "system_tools");
+ try makeDir(tmp.dir, "base_tools");
try makeDir(tmp.dir, "user_tools");
try makeDir(tmp.dir, "project_tools");
- try writeFile(tmp.dir, "system_tools/greet.lua",
+ try writeFile(tmp.dir, "base_tools/greet.lua",
\\return {
- \\ name = "greet", description = "system version",
+ \\ name = "greet", description = "base version",
\\ schema = { type = "object" },
- \\ handler = function(input) return "SYSTEM" end,
+ \\ handler = function(input) return "BASE" end,
\\}
);
try writeFile(tmp.dir, "user_tools/greet.lua",
@@ -780,7 +834,7 @@ test "loadFromDirs: project tool shadows user shadows system" {
);
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const sys_len = try tmp.dir.realPathFile(testing.io, "system_tools", &path_buf);
+ const sys_len = try tmp.dir.realPathFile(testing.io, "base_tools", &path_buf);
const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]);
defer testing.allocator.free(sys_path);
const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf);
@@ -794,10 +848,10 @@ test "loadFromDirs: project tool shadows user shadows system" {
defer rt.deinit();
const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .system_tools = sys_path,
+ .base_tools = sys_path,
.user_tools = user_path,
.project_tools = proj_path,
- });
+ }, .{});
try testing.expectEqual(@as(usize, 1), n_tools);
var src = rt.toolSource();
@@ -808,18 +862,18 @@ test "loadFromDirs: project tool shadows user shadows system" {
try testing.expectEqualStrings("PROJECT", results[0].ok);
}
-test "loadFromDirs: user tool shadows system tool when no project entry" {
+test "loadFromDirs: user tool shadows base tool when no project entry" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "system_tools");
+ try makeDir(tmp.dir, "base_tools");
try makeDir(tmp.dir, "user_tools");
- try writeFile(tmp.dir, "system_tools/greet.lua",
+ try writeFile(tmp.dir, "base_tools/greet.lua",
\\return {
- \\ name = "greet", description = "system",
+ \\ name = "greet", description = "base",
\\ schema = { type = "object" },
- \\ handler = function(input) return "SYSTEM" end,
+ \\ handler = function(input) return "BASE" end,
\\}
);
try writeFile(tmp.dir, "user_tools/greet.lua",
@@ -831,7 +885,7 @@ test "loadFromDirs: user tool shadows system tool when no project entry" {
);
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const sys_len = try tmp.dir.realPathFile(testing.io, "system_tools", &path_buf);
+ const sys_len = try tmp.dir.realPathFile(testing.io, "base_tools", &path_buf);
const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]);
defer testing.allocator.free(sys_path);
const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf);
@@ -842,9 +896,9 @@ test "loadFromDirs: user tool shadows system tool when no project entry" {
defer rt.deinit();
const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .system_tools = sys_path,
+ .base_tools = sys_path,
.user_tools = user_path,
- });
+ }, .{});
try testing.expectEqual(@as(usize, 1), n_tools);
var src = rt.toolSource();
@@ -895,6 +949,87 @@ test "loadFromDirs: extension and tool share a *file* name independently" {
const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
.user_extensions = ext_path,
.user_tools = tools_path,
- });
+ }, .{});
try testing.expectEqual(@as(usize, 2), n_tools);
}
+
+test "loadFromDirs: tools policy removes a denied registered tool name" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ try makeDir(tmp.dir, "tools");
+ try writeFile(tmp.dir, "tools/reader.lua",
+ \\return {
+ \\ name = "std.read", description = "r",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "READ" end,
+ \\}
+ );
+ try writeFile(tmp.dir, "tools/sheller.lua",
+ \\return {
+ \\ name = "std.shell", description = "s",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "SHELL" end,
+ \\}
+ );
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf);
+ const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]);
+ defer testing.allocator.free(tools_path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+
+ var deny = try testing.allocator.alloc([]const u8, 1);
+ deny[0] = try testing.allocator.dupe(u8, "std.shell");
+ const tools_policy: config_file.Policy = .{ .allow = &.{}, .deny = deny };
+ defer tools_policy.deinit(testing.allocator);
+
+ // Both tools register, but std.shell is filtered out post-load.
+ const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
+ .user_tools = tools_path,
+ }, .{ .tools = &tools_policy });
+ try testing.expectEqual(@as(usize, 1), n_tools);
+
+ var src = rt.toolSource();
+ const calls = [_]panto.ToolCall{.{ .tool_name = "std.read", .input = "{}" }};
+ var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+ defer testing.allocator.free(results[0].ok);
+ try testing.expectEqualStrings("READ", results[0].ok);
+}
+
+test "loadFromDirs: extensions policy denies a whole entry before it loads" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ try makeDir(tmp.dir, "tools");
+ // This tool would crash if its script ran (calls a nil global). The
+ // extensions policy must stop it from loading at all.
+ try writeFile(tmp.dir, "danger.lua",
+ \\error("this script must never run")
+ );
+ try makeDir(tmp.dir, "td");
+ try writeFile(tmp.dir, "td/danger.lua",
+ \\error("this script must never run")
+ );
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const n = try tmp.dir.realPathFile(testing.io, "td", &path_buf);
+ const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]);
+ defer testing.allocator.free(tools_path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+
+ var deny = try testing.allocator.alloc([]const u8, 1);
+ deny[0] = try testing.allocator.dupe(u8, "danger");
+ const ext_policy: config_file.Policy = .{ .allow = &.{}, .deny = deny };
+ defer ext_policy.deinit(testing.allocator);
+
+ const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
+ .user_tools = tools_path,
+ }, .{ .extensions = &ext_policy });
+ try testing.expectEqual(@as(usize, 0), n_tools);
+}
diff --git a/src/glob.zig b/src/glob.zig
new file mode 100644
index 0000000..83d9fcb
--- /dev/null
+++ b/src/glob.zig
@@ -0,0 +1,121 @@
+//! Minimal glob matcher for tool/extension allow- and deny-lists.
+//!
+//! Patterns and names are dot-delimited segments (e.g. `std.git.status`).
+//! Matching operates on whole segments, never on individual characters.
+//!
+//! Supported segment wildcards:
+//! - `*` matches exactly one segment. `std.*` matches `std.read` but
+//! not `std.git.status` (greedy within a segment, but not
+//! dot-agnostic).
+//! - `**` matches one or more segments. `std.**` matches both
+//! `std.read` and `std.git.status`.
+//!
+//! A wildcard is only recognized as a whole segment: `std.*` and `std.**`
+//! are wildcards, but `std.rea*` is a literal segment (the `*` has no
+//! special meaning mid-segment). Everything else is matched literally.
+
+const std = @import("std");
+
+/// Returns true if `name` matches `pattern` under the segment rules above.
+pub fn match(pattern: []const u8, name: []const u8) bool {
+ var pat_it = std.mem.splitScalar(u8, pattern, '.');
+ var name_it = std.mem.splitScalar(u8, name, '.');
+ return matchSegments(&pat_it, &name_it);
+}
+
+const SegmentIter = std.mem.SplitIterator(u8, .scalar);
+
+fn matchSegments(pat_it: *SegmentIter, name_it: *SegmentIter) bool {
+ while (pat_it.next()) |seg| {
+ if (std.mem.eql(u8, seg, "**")) {
+ // `**` matches one or more remaining segments. Try the
+ // shortest match first (one segment), then extend.
+ const rest_pat = pat_it.*; // pattern after `**`
+ while (name_it.next()) |_| {
+ var pat_copy = rest_pat;
+ var name_copy = name_it.*;
+ if (matchSegments(&pat_copy, &name_copy)) return true;
+ }
+ // `**` requires at least one segment, and the trailing
+ // pattern must have matched the remainder above.
+ return false;
+ }
+
+ const name_seg = name_it.next() orelse return false;
+ if (std.mem.eql(u8, seg, "*")) {
+ // Matches exactly one (any) segment.
+ continue;
+ }
+ if (!std.mem.eql(u8, seg, name_seg)) return false;
+ }
+
+ // Pattern exhausted: match iff name is also exhausted.
+ return name_it.next() == null;
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+test "literal match" {
+ try testing.expect(match("std.read", "std.read"));
+ try testing.expect(!match("std.read", "std.write"));
+ try testing.expect(!match("std.read", "std.read.more"));
+ try testing.expect(!match("std.read.more", "std.read"));
+}
+
+test "single star matches exactly one segment" {
+ try testing.expect(match("std.*", "std.read"));
+ try testing.expect(match("std.*", "std.write"));
+ try testing.expect(!match("std.*", "std.git.status"));
+ try testing.expect(!match("std.*", "std"));
+ try testing.expect(!match("std.*", "other.read"));
+}
+
+test "double star is dot-agnostic, one or more segments" {
+ try testing.expect(match("std.**", "std.read"));
+ try testing.expect(match("std.**", "std.git.status"));
+ try testing.expect(!match("std.**", "std"));
+ try testing.expect(!match("std.**", "other.read"));
+}
+
+test "bare single star matches one segment" {
+ try testing.expect(match("*", "std"));
+ try testing.expect(!match("*", "std.read"));
+ // An empty name is a single empty segment, which `*` matches.
+ try testing.expect(match("*", ""));
+}
+
+test "bare double star matches everything non-empty" {
+ try testing.expect(match("**", "std"));
+ try testing.expect(match("**", "std.read"));
+ try testing.expect(match("**", "std.git.status"));
+}
+
+test "star in middle" {
+ try testing.expect(match("*.read", "std.read"));
+ try testing.expect(match("*.*", "std.read"));
+ try testing.expect(!match("*.*", "std.git.status"));
+ try testing.expect(match("std.*.status", "std.git.status"));
+ try testing.expect(!match("std.*.status", "std.status"));
+}
+
+test "double star in middle" {
+ try testing.expect(match("std.**.status", "std.git.status"));
+ try testing.expect(match("std.**.status", "std.a.b.status"));
+ try testing.expect(!match("std.**.status", "std.status"));
+ try testing.expect(match("**.status", "std.git.status"));
+}
+
+test "wildcard only recognized as whole segment" {
+ // A `*` embedded in a segment is a literal, not a wildcard.
+ try testing.expect(match("std.rea*", "std.rea*"));
+ try testing.expect(!match("std.rea*", "std.read"));
+}
+
+test "empty pattern only matches empty name" {
+ try testing.expect(match("", ""));
+ try testing.expect(!match("", "x"));
+}
diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig
index 5efadaa..fed56a8 100644
--- a/src/lua_runtime.zig
+++ b/src/lua_runtime.zig
@@ -354,6 +354,38 @@ pub const LuaRuntime = struct {
pub fn toolCount(self: *const LuaRuntime) usize {
return self.decls.items.len;
}
+
+ /// Drop every declared tool whose registered name is rejected by
+ /// `permits`. The handler ref is unref'd and the decl removed.
+ /// Returns the number of tools dropped.
+ ///
+ /// String storage for a dropped tool's name/description/schema is
+ /// left in `self.strings` (freed at `deinit`); only the decl entry
+ /// and the Lua handler ref are reclaimed eagerly. This keeps the
+ /// filter simple — we never need to find-and-free individual
+ /// strings out of the shared pool.
+ pub fn filterTools(
+ self: *LuaRuntime,
+ ctx: anytype,
+ comptime permits: fn (@TypeOf(ctx), []const u8) bool,
+ ) usize {
+ var dropped: usize = 0;
+ var i: usize = 0;
+ while (i < self.decls.items.len) {
+ const name = self.decls.items[i].name;
+ if (permits(ctx, name)) {
+ i += 1;
+ continue;
+ }
+ // Unref the handler, if present.
+ if (self.handlers.fetchRemove(name)) |kv| {
+ c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, kv.value);
+ }
+ _ = self.decls.orderedRemove(i);
+ dropped += 1;
+ }
+ return dropped;
+ }
};
const source_vtable: panto.ToolSource.VTable = .{
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
index 0114b64..e652096 100644
--- a/src/luarocks_runtime.zig
+++ b/src/luarocks_runtime.zig
@@ -137,6 +137,7 @@ pub fn bootstrap(
try panto_home.ensureDirsExist(layout, io);
try stageLuaHeaders(allocator, io, layout);
try stageAgentTree(allocator, io, layout);
+ try stageDefaultConfig(allocator, io, layout);
const lua_wrapper_path = try writeLuaWrapper(allocator, io, layout, panto_executable_path);
defer allocator.free(lua_wrapper_path);
try writeLuarocksConfig(allocator, io, layout, lua_wrapper_path);
@@ -236,6 +237,71 @@ fn stageAgentTree(
}
}
+/// The default base `config.toml`, materialized at `$PANTO_HOME/config.toml`
+/// on first run if absent. It declares OpenAI and Anthropic providers whose
+/// API keys come from the conventional env vars — so a provider simply
+/// doesn't appear unless its key is exported. Edit or override at the user
+/// (`~/.config/panto/config.toml`) or project (`./.panto/config.toml`) layer.
+const default_base_config =
+ \\# panto base config (auto-generated on first run).
+ \\#
+ \\# This is the lowest-precedence layer. Override anything here from
+ \\# ~/.config/panto/config.toml (user)
+ \\# ./.panto/config.toml (project)
+ \\# Tables merge across layers; scalars and arrays from a higher layer
+ \\# replace the lower one wholesale.
+ \\#
+ \\# A provider whose API key (or its api_key_env_var) is missing at
+ \\# runtime is silently dropped — so these defaults disappear unless
+ \\# you've exported the corresponding key.
+ \\
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key_env_var = "OPENAI_API_KEY"
+ \\
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ \\
+ \\# Pick the default model with `<provider>:<alias>`. The alias is
+ \\# resolved against models.toml; an unknown alias is used verbatim as
+ \\# the wire model name.
+ \\#
+ \\# [defaults]
+ \\# model = "anthropic:claude-sonnet-4-20250514"
+ \\
+ \\# Tool/extension availability (glob patterns). Empty allow = allow all.
+ \\# [tools]
+ \\# allow = ["std.*"]
+ \\# deny = []
+ \\
+;
+
+/// Materialize the default base config at `$PANTO_HOME/config.toml`,
+/// but only if no file exists there yet. We never overwrite — the base
+/// config is user-editable, and a stale-but-edited file must win over the
+/// shipped default.
+fn stageDefaultConfig(
+ allocator: Allocator,
+ io: Io,
+ layout: panto_home.Layout,
+) !void {
+ _ = allocator;
+ var home = try Io.Dir.cwd().openDir(io, layout.home, .{});
+ defer home.close(io);
+
+ home.access(io, "config.toml", .{}) catch |err| switch (err) {
+ error.FileNotFound => {
+ try home.writeFile(io, .{ .sub_path = "config.toml", .data = default_base_config });
+ return;
+ },
+ else => return err,
+ };
+ // Exists already — leave it untouched.
+}
+
/// Write `contents` into `dir/name` only if the existing file differs.
/// Creates the file if absent.
fn writeIfDifferent(
diff --git a/src/main.zig b/src/main.zig
index bdb5390..aaf86b5 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -9,6 +9,8 @@ const self_exe = @import("self_exe.zig");
const subcommand = @import("subcommand.zig");
const session_paths = @import("session_paths.zig");
const models_toml = @import("models_toml.zig");
+const config_file = @import("config_file.zig");
+const glob = @import("glob.zig");
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
@@ -33,6 +35,8 @@ test {
_ = self_exe;
_ = subcommand;
_ = models_toml;
+ _ = config_file;
+ _ = glob;
}
const Receiver = panto.provider.Receiver;
@@ -195,74 +199,58 @@ fn luaSmokeCheck() void {
lua.lua_settop(L, 0);
}
-fn loadConfig(environ_map: *const std.process.Environ.Map) !panto.config.Config {
- const style_str = environ_map.get("PANTO_API_STYLE") orelse "openai_chat";
- const style = std.meta.stringToEnum(panto.config.APIStyle, style_str) orelse {
- std.debug.print(
- "error: PANTO_API_STYLE must be one of: openai_chat, anthropic_messages (got: {s})\n",
- .{style_str},
- );
- return error.InvalidApiStyle;
- };
+/// Print a friendly message for a config.toml load failure before the
+/// process exits non-zero.
+fn reportConfigError(err: anyerror) void {
+ switch (err) {
+ error.InvalidConfigToml => std.debug.print(
+ "error: a config.toml failed to parse (see log above).\n",
+ .{},
+ ),
+ error.InvalidProvider => std.debug.print(
+ "error: a [providers.*] entry is malformed (need style + base_url).\n",
+ .{},
+ ),
+ error.InvalidModelRef => std.debug.print(
+ "error: defaults.model must look like \"provider:model\".\n",
+ .{},
+ ),
+ error.UnknownDefaultProvider => std.debug.print(
+ "error: defaults.model names a provider that isn't configured (or whose API key env var isn't set).\n",
+ .{},
+ ),
+ error.PolicyConflict => std.debug.print(
+ "error: a tool/extension pattern appears in both allow and deny.\n",
+ .{},
+ ),
+ error.NoHomeDirectory => std.debug.print(
+ "error: cannot resolve config paths — set HOME or XDG_CONFIG_HOME/XDG_DATA_HOME.\n",
+ .{},
+ ),
+ else => std.debug.print("error: failed to load config: {s}\n", .{@errorName(err)}),
+ }
+}
- switch (style) {
- .openai_chat => {
- const api_key = environ_map.get("OPENAI_API_KEY") orelse {
- std.debug.print("error: OPENAI_API_KEY is required\n", .{});
- return error.MissingApiKey;
- };
- const base_url = environ_map.get("OPENAI_BASE_URL") orelse "https://api.openai.com/v1";
- const model = environ_map.get("OPENAI_MODEL") orelse {
- std.debug.print("error: OPENAI_MODEL is required\n", .{});
- return error.MissingModel;
- };
- const reasoning: panto.config.ReasoningEffort =
- if (environ_map.get("OPENAI_REASONING")) |val|
- std.meta.stringToEnum(panto.config.ReasoningEffort, val) orelse {
- std.debug.print(
- "error: OPENAI_REASONING must be one of: default, off, minimal, low, medium, high (got: {s})\n",
- .{val},
- );
- return error.InvalidReasoning;
- }
- else
- .default;
- return .{ .openai_chat = .{
- .api_key = api_key,
- .base_url = base_url,
- .model = model,
- .reasoning = reasoning,
- } };
- },
- .anthropic_messages => {
- const api_key = environ_map.get("ANTHROPIC_API_KEY") orelse {
- std.debug.print("error: ANTHROPIC_API_KEY is required\n", .{});
- return error.MissingApiKey;
- };
- const base_url = environ_map.get("ANTHROPIC_BASE_URL") orelse "https://api.anthropic.com";
- const model = environ_map.get("ANTHROPIC_MODEL") orelse {
- std.debug.print("error: ANTHROPIC_MODEL is required\n", .{});
- return error.MissingModel;
- };
- const api_version = environ_map.get("ANTHROPIC_API_VERSION") orelse "2023-06-01";
- const max_tokens: u32 = if (environ_map.get("ANTHROPIC_MAX_TOKENS")) |val|
- std.fmt.parseInt(u32, val, 10) catch {
- std.debug.print(
- "error: ANTHROPIC_MAX_TOKENS must be a positive integer (got: {s})\n",
- .{val},
- );
- return error.InvalidMaxTokens;
- }
- else
- 4096;
- return .{ .anthropic_messages = .{
- .api_key = api_key,
- .base_url = base_url,
- .model = model,
- .api_version = api_version,
- .max_tokens = max_tokens,
- } };
+/// Print a friendly message for a model-selection failure.
+fn reportModelError(err: anyerror, cfg: *const config_file.Config) void {
+ switch (err) {
+ error.NoModelSelected => {
+ std.debug.print(
+ "error: no model selected. Set `defaults.model = \"provider:alias\"` in config.toml.\n",
+ .{},
+ );
+ if (cfg.providers.len == 0) {
+ std.debug.print(
+ " (no providers resolved — check that an API key or its env var is present.)\n",
+ .{},
+ );
+ }
},
+ error.UnknownProvider => std.debug.print(
+ "error: the selected model names an unconfigured provider.\n",
+ .{},
+ ),
+ else => std.debug.print("error: model selection failed: {s}\n", .{@errorName(err)}),
}
}
@@ -294,8 +282,6 @@ pub fn main(init: std.process.Init) !void {
.agent => {},
}
- const config = try loadConfig(init.environ_map);
-
// Parse the agent-mode flags. Currently only `--resume [<id>]`.
const cli_flags = try parseAgentFlags(alloc, init.minimal.args);
defer cli_flags.deinit(alloc);
@@ -315,19 +301,47 @@ pub fn main(init: std.process.Init) !void {
const session_dir = try session_paths.sessionDirForCwd(alloc, init.environ_map, cwd);
defer alloc.free(session_dir);
- // Load the user's models.toml — a missing file is fine (empty
- // registry). Cost lookups against an empty registry return null,
- // and the display layer will format that as "unknown."
+ // Load the merged config.toml (base → user → project). Providers,
+ // default model, and tool/extension policy all come from here.
+ var app_config = config_file.load(alloc, io, init.environ_map, cwd) catch |err| {
+ reportConfigError(err);
+ std.process.exit(1);
+ };
+ defer app_config.deinit();
+
+ // Load models.toml — model aliases (wire name + knobs) and pricing.
+ // A missing file is fine (empty registries); cost lookups return
+ // null, formatted as "unknown" by the display layer.
const models_toml_path = try models_toml.configPath(alloc, init.environ_map);
defer alloc.free(models_toml_path);
- var pricing_registry = try models_toml.loadFromPath(alloc, io, models_toml_path);
- defer pricing_registry.deinit();
- std.log.debug("models.toml: {d} entries from {s}", .{ pricing_registry.count(), models_toml_path });
+ var models = try models_toml.loadFromPath(alloc, io, models_toml_path);
+ defer models.deinit();
+ std.log.debug(
+ "models.toml: {d} model def(s), {d} price entr(ies) from {s}",
+ .{ models.defs.count(), models.pricing.count(), models_toml_path },
+ );
+ // `models.pricing` is parsed and ready; the print-based CLI doesn't
+ // surface per-turn cost yet (that lands with the TUI frontend).
- const banner_model_initial: []const u8 = switch (config) {
+ // Choose the active model and assemble the provider config.
+ const model_ref = app_config.selectModel(&models.defs, null) catch |err| {
+ reportModelError(err, &app_config);
+ std.process.exit(1);
+ };
+ const provider_config = config_file.buildProviderConfig(&app_config, &models.defs, model_ref) catch |err| {
+ reportModelError(err, &app_config);
+ std.process.exit(1);
+ };
+
+ // Process-global HTTP client: one connection pool for every provider /
+ // base_url the agent may switch to. Torn down at shutdown.
+ panto.config.initHttp(alloc, io);
+ defer panto.config.deinitHttp();
+
+ const banner_model_initial: []const u8 = switch (provider_config) {
inline else => |c| c.model,
};
- const banner_provider_initial: []const u8 = @tagName(config);
+ const banner_provider_initial: []const u8 = model_ref.provider;
// Create or resume the session. Resume failures (missing/ambiguous id)
// are user errors — print a tidy message and exit 1 rather than
@@ -365,9 +379,11 @@ pub fn main(init: std.process.Init) !void {
try appendSystemToSession(alloc, &session_mgr, system_text);
}
- const prov = try panto.provider.Provider.init(alloc, io, config);
- var agent = panto.agent.Agent.init(alloc, io, prov);
- defer agent.deinit();
+ // The tool registry is owned here and referenced by the active config
+ // snapshot. Extensions register their tool source into it *before* the
+ // snapshot is built, so the agent's first turn sees them.
+ var registry = panto.ToolRegistry.init(alloc);
+ defer registry.deinit();
// Spin up the long-lived Lua runtime. All Lua extensions load into
// one `lua_State`; module-global state survives across calls. The
@@ -394,10 +410,10 @@ pub fn main(init: std.process.Init) !void {
// chance to register tools that might want to yield.
try rt.installScheduler();
- // Discover Lua extensions across three layers — system
+ // Discover Lua extensions across three layers — base
// ($PANTO_HOME/agent), user ($XDG_CONFIG_HOME/panto or
// $HOME/.config/panto), and project (./.panto). Project shadows
- // user shadows system; tool-name collisions across surviving
+ // user shadows base; tool-name collisions across surviving
// entries abort startup.
const n_ext_tools = extension_loader.discoverAndLoad(
alloc,
@@ -405,6 +421,10 @@ pub fn main(init: std.process.Init) !void {
init.environ_map,
luarocks_rt.layout.agent_dir,
rt,
+ .{
+ .extensions = &app_config.extensions,
+ .tools = &app_config.tools,
+ },
) catch |err| {
std.log.err("extension discovery failed: {t}", .{err});
return err;
@@ -412,10 +432,20 @@ pub fn main(init: std.process.Init) !void {
std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools});
if (n_ext_tools > 0) {
- try agent.registerToolSource(rt.toolSource());
+ try registry.registerSource(rt.toolSource());
}
- const banner_base: []const u8 = switch (config) {
+ // Assemble the active configuration snapshot (provider + tool registry)
+ // and create the agent holding a pointer to it. Swapping this pointer
+ // later (model/provider/tool changes) takes effect at the next turn.
+ const active_config: panto.config.Config = .{
+ .provider = provider_config,
+ .registry = &registry,
+ };
+ var agent = panto.agent.Agent.init(alloc, io, &active_config);
+ defer agent.deinit();
+
+ const banner_base: []const u8 = switch (provider_config) {
inline else => |c| c.base_url,
};
try stdout.print(
diff --git a/src/models_toml.zig b/src/models_toml.zig
index 5ccb302..36d8c65 100644
--- a/src/models_toml.zig
+++ b/src/models_toml.zig
@@ -1,46 +1,46 @@
-//! Loader for `~/.config/panto/models.toml`.
+//! Loader for `models.toml` — model aliases and per-model pricing.
//!
-//! Schema:
+//! A model entry is keyed by `<provider_name>.<alias>`, where
+//! `<provider_name>` matches a provider defined in `config.toml`'s
+//! `[providers.<name>]` table, and `<alias>` is the short name the user
+//! references via `<provider_name>:<alias>` (e.g. `anthropic:sonnet`).
//!
-//! [<provider>.<model>]
-//! input = <float> # USD per million tokens (fresh input)
-//! output = <float> # USD per million tokens
-//! cache_read = <float> # USD per million tokens (optional; default "unknown")
-//! cache_write = <float> # USD per million tokens (optional; default "unknown")
+//! Schema:
//!
-//! All four price fields are optional at the parse layer. Any field
-//! omitted from the TOML comes through as `null` in the in-memory
-//! `Pricing`, which means "unknown price for this token category" —
-//! NOT "zero." If the model later reports usage in an unknown
-//! category (e.g. gpt-4o reads from prompt cache but the TOML omitted
-//! `cache_read`), session cost degenerates to "unknown" rather than
-//! silently treating that usage as free. To declare a category as a
-//! known zero (e.g. OpenAI doesn't bill a cache-write rate), write
-//! `cache_write = 0` explicitly.
+//! [<provider>.<alias>]
+//! model = <string> # wire model id; defaults to <alias> if omitted
+//! reasoning = <string> # default | off | minimal | low | medium | high
+//! max_tokens = <int> # anthropic_messages only
+//! api_version = <string> # anthropic_messages only
+//! # pricing (all optional; USD per million tokens):
+//! input = <float>
+//! output = <float>
+//! cache_read = <float>
+//! cache_write = <float>
//!
-//! `<provider>` is `openai` or `anthropic` (matching pantograph's API
-//! styles). `<model>` is the model id pantograph sends to the API. Both
-//! are TOML "dotted-key" path segments; quote them if they contain
-//! characters TOML doesn't allow bare (`-`, `.`, `:` etc. — `-` is OK
-//! bare; `.` requires quoting since dots separate path segments).
+//! Pricing semantics are unchanged from the original models.toml: any
+//! omitted price field is "unknown" (null), not zero. Write `= 0`
+//! explicitly to declare a known-zero rate.
//!
//! Example:
//!
-//! [anthropic."claude-sonnet-4-20250514"]
+//! [anthropic.sonnet]
+//! model = "claude-sonnet-4-20250514"
+//! reasoning = "high"
+//! max_tokens = 8192
//! input = 3.0
//! output = 15.0
//! cache_read = 0.3
//! cache_write = 3.75
//!
-//! [openai.gpt-4o]
+//! [openai.gpt]
+//! model = "gpt-4o"
//! input = 2.5
//! output = 10.0
//!
-//! The model id in the section header may also be unquoted when it
-//! contains no `.` or other reserved chars (e.g. `gpt-4o`); pantograph
-//! reads either form. We do not currently bake in default pricing —
-//! a missing entry (or a present entry with missing fields) surfaces
-//! as "unknown cost" and the display layer formats accordingly.
+//! A referenced alias with no entry here is not an error: the alias is
+//! used verbatim as the wire model name (zero-config convenience), with
+//! default knobs and unknown pricing.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -51,6 +51,71 @@ const panto = @import("panto");
pub const Pricing = panto.pricing.Pricing;
pub const Registry = panto.pricing.Registry;
+pub const ReasoningEffort = panto.config.ReasoningEffort;
+
+/// A resolved model definition: the wire model id plus per-model knobs.
+/// Strings are owned by the `ModelRegistry`.
+pub const ModelDef = struct {
+ /// `<provider_name>` — matches a `[providers.<name>]` in config.toml.
+ provider: []const u8,
+ /// `<alias>` — the short reference name.
+ alias: []const u8,
+ /// Wire model id sent to the provider API. Equals `alias` when the
+ /// TOML omitted `model`.
+ model: []const u8,
+ reasoning: ReasoningEffort,
+ /// anthropic_messages only; null = use the provider/library default.
+ max_tokens: ?u32,
+ /// anthropic_messages only; null = use the library default.
+ api_version: ?[]const u8,
+};
+
+/// In-memory map of `(provider, alias) -> ModelDef`. Owns all strings.
+pub const ModelRegistry = struct {
+ allocator: Allocator,
+ entries: std.ArrayList(ModelDef),
+
+ pub fn init(allocator: Allocator) ModelRegistry {
+ return .{ .allocator = allocator, .entries = .empty };
+ }
+
+ pub fn deinit(self: *ModelRegistry) void {
+ for (self.entries.items) |e| {
+ self.allocator.free(e.provider);
+ self.allocator.free(e.alias);
+ self.allocator.free(e.model);
+ if (e.api_version) |v| self.allocator.free(v);
+ }
+ self.entries.deinit(self.allocator);
+ }
+
+ pub fn count(self: *const ModelRegistry) usize {
+ return self.entries.items.len;
+ }
+
+ /// Look up a model definition by provider name + alias.
+ pub fn get(self: *const ModelRegistry, provider: []const u8, alias: []const u8) ?ModelDef {
+ for (self.entries.items) |e| {
+ if (std.mem.eql(u8, e.provider, provider) and std.mem.eql(u8, e.alias, alias)) {
+ return e;
+ }
+ }
+ return null;
+ }
+};
+
+/// Everything `models.toml` yields: model definitions and pricing. The
+/// two registries are independent (pricing keys on the *wire* model id;
+/// model defs key on the alias) but parsed in one pass.
+pub const Models = struct {
+ defs: ModelRegistry,
+ pricing: Registry,
+
+ pub fn deinit(self: *Models) void {
+ self.defs.deinit();
+ self.pricing.deinit();
+ }
+};
/// Resolve the absolute path to `models.toml`. Honors `XDG_CONFIG_HOME`,
/// falling back to `$HOME/.config`. Caller owns the returned slice.
@@ -64,21 +129,17 @@ pub fn configPath(allocator: Allocator, environ_map: *const std.process.Environ.
return error.NoHomeDirectory;
}
-/// Load `models.toml` into a fresh `Registry`. If the file does not
-/// exist, returns an empty registry (no error — missing config is fine).
-///
-/// On parse errors, returns `error.InvalidModelsToml` after logging the
-/// line/column of the first error.
-pub fn loadFromPath(
- allocator: Allocator,
- io: Io,
- path: []const u8,
-) !Registry {
- var reg = Registry.init(allocator);
- errdefer reg.deinit();
+/// Load `models.toml` from `path`. A missing file yields empty registries
+/// (no error). Parse errors return `error.InvalidModelsToml`.
+pub fn loadFromPath(allocator: Allocator, io: Io, path: []const u8) !Models {
+ var models: Models = .{
+ .defs = ModelRegistry.init(allocator),
+ .pricing = Registry.init(allocator),
+ };
+ errdefer models.deinit();
const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
- error.FileNotFound => return reg, // empty registry; perfectly fine.
+ error.FileNotFound => return models, // empty; perfectly fine.
else => return err,
};
defer file.close(io);
@@ -88,18 +149,16 @@ pub fn loadFromPath(
defer allocator.free(bytes);
_ = try file.readPositionalAll(io, bytes, 0);
- try parseInto(&reg, bytes);
- return reg;
+ try parseInto(&models, bytes);
+ return models;
}
-/// Parse a TOML string into the given registry. Useful for tests.
-pub fn parseInto(reg: *Registry, source: []const u8) !void {
- const alloc = reg.allocator;
+/// Parse a TOML string into the given registries. Useful for tests.
+pub fn parseInto(models: *Models, source: []const u8) !void {
+ const alloc = models.defs.allocator;
const result = toml.parseWithError(alloc, source, .{});
switch (result) {
.err => |e| {
- // Silenced in tests so the test runner doesn't flag the
- // expected-failure case as a real error.
if (!@import("builtin").is_test) {
std.log.err(
"models.toml: parse error at line {d}, column {d}: {s}",
@@ -113,31 +172,98 @@ pub fn parseInto(reg: *Registry, source: []const u8) !void {
var d = doc;
d.deinit();
}
- try ingestDocument(reg, doc);
+ try ingestDocument(models, doc);
},
}
}
-fn ingestDocument(reg: *Registry, doc: *toml.Document) !void {
- // Root is a table of provider -> table of model -> { input, output, ... }.
+fn ingestDocument(models: *Models, doc: *toml.Document) !void {
const root_val: *const toml.Value = doc.root;
if (root_val.* != .table) return;
+
var provider_it = toml.tableIterator(root_val);
while (provider_it.next()) |provider_entry| {
const provider = provider_entry.key;
const provider_val: *const toml.Value = provider_entry.value;
if (provider_val.* != .table) continue;
- var model_it = toml.tableIterator(provider_val);
- while (model_it.next()) |model_entry| {
- const model = model_entry.key;
- const v: *const toml.Value = model_entry.value;
+
+ var alias_it = toml.tableIterator(provider_val);
+ while (alias_it.next()) |alias_entry| {
+ const alias = alias_entry.key;
+ const v: *const toml.Value = alias_entry.value;
if (v.* != .table) continue;
- const pricing = pricingFromValue(v);
- try reg.set(provider, model, pricing);
+
+ try ingestModel(models, provider, alias, v);
}
}
}
+fn ingestModel(
+ models: *Models,
+ provider: []const u8,
+ alias: []const u8,
+ v: *const toml.Value,
+) !void {
+ const alloc = models.defs.allocator;
+
+ // Wire model id: explicit `model`, else the alias itself.
+ const wire_model: []const u8 = blk: {
+ if (v.get("model")) |m| {
+ if (m.asString()) |s| break :blk s;
+ }
+ break :blk alias;
+ };
+
+ const reasoning: ReasoningEffort = blk: {
+ if (v.get("reasoning")) |r| {
+ if (r.asString()) |s| {
+ break :blk std.meta.stringToEnum(ReasoningEffort, s) orelse .default;
+ }
+ }
+ break :blk .default;
+ };
+
+ const max_tokens: ?u32 = blk: {
+ if (v.get("max_tokens")) |mt| {
+ if (mt.asI64()) |i| {
+ if (i > 0) break :blk @intCast(i);
+ }
+ }
+ break :blk null;
+ };
+
+ const api_version_src: ?[]const u8 = blk: {
+ if (v.get("api_version")) |av| {
+ if (av.asString()) |s| break :blk s;
+ }
+ break :blk null;
+ };
+
+ // Own all the strings.
+ const provider_copy = try alloc.dupe(u8, provider);
+ errdefer alloc.free(provider_copy);
+ const alias_copy = try alloc.dupe(u8, alias);
+ errdefer alloc.free(alias_copy);
+ const model_copy = try alloc.dupe(u8, wire_model);
+ errdefer alloc.free(model_copy);
+ const api_version_copy: ?[]const u8 = if (api_version_src) |s| try alloc.dupe(u8, s) else null;
+ errdefer if (api_version_copy) |s| alloc.free(s);
+
+ try models.defs.entries.append(alloc, .{
+ .provider = provider_copy,
+ .alias = alias_copy,
+ .model = model_copy,
+ .reasoning = reasoning,
+ .max_tokens = max_tokens,
+ .api_version = api_version_copy,
+ });
+
+ // Pricing keys on the *wire* model id so usage records (which carry
+ // the wire model) resolve directly.
+ const pricing = pricingFromValue(v);
+ try models.pricing.set(provider, wire_model, pricing);
+}
+
fn pricingFromValue(v: *const toml.Value) Pricing {
return .{
.input = readPriceField(v, "input"),
@@ -148,12 +274,9 @@ fn pricingFromValue(v: *const toml.Value) Pricing {
}
/// Returns `null` if the field is absent or has a type we can't
-/// interpret as a price. An explicit `0` (or `0.0`) comes through as a
-/// known zero, not `null` — callers rely on that distinction.
+/// interpret as a price. An explicit `0` comes through as a known zero.
fn readPriceField(table: *const toml.Value, name: []const u8) ?u64 {
const field = table.get(name) orelse return null;
- // Accept either floats (the natural form) or integers (the user
- // wrote `3` instead of `3.0`).
if (field.asF64()) |f| return Pricing.fromDollarsPerMtok(f);
if (field.asI64()) |i| return Pricing.fromDollarsPerMtok(@floatFromInt(i));
return null;
@@ -165,108 +288,116 @@ fn readPriceField(table: *const toml.Value, name: []const u8) ?u64 {
const testing = std.testing;
-test "parseInto: two providers, multiple models" {
- var reg = Registry.init(testing.allocator);
- defer reg.deinit();
+fn emptyModels(a: Allocator) Models {
+ return .{ .defs = ModelRegistry.init(a), .pricing = Registry.init(a) };
+}
+
+test "parseInto: model defs carry wire name and knobs, keyed by provider.alias" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
const src =
- \\[anthropic."claude-sonnet-4-20250514"]
+ \\[anthropic.sonnet]
+ \\model = "claude-sonnet-4-20250514"
+ \\reasoning = "high"
+ \\max_tokens = 8192
\\input = 3.0
\\output = 15.0
- \\cache_read = 0.3
- \\cache_write = 3.75
\\
- \\[openai.gpt-4o]
+ \\[openai.gpt]
+ \\model = "gpt-4o"
\\input = 2.5
\\output = 10.0
- \\cache_read = 1.25
- \\
- \\[openai.gpt-4o-mini]
- \\input = 0.15
- \\output = 0.6
;
- try parseInto(&reg, src);
+ try parseInto(&models, src);
- const anth = reg.get("anthropic", "claude-sonnet-4-20250514").?;
- try testing.expectEqual(@as(?u64, 300), anth.input);
- try testing.expectEqual(@as(?u64, 1500), anth.output);
- try testing.expectEqual(@as(?u64, 30), anth.cache_read);
- try testing.expectEqual(@as(?u64, 375), anth.cache_write);
+ const sonnet = models.defs.get("anthropic", "sonnet").?;
+ try testing.expectEqualStrings("claude-sonnet-4-20250514", sonnet.model);
+ try testing.expectEqual(ReasoningEffort.high, sonnet.reasoning);
+ try testing.expectEqual(@as(?u32, 8192), sonnet.max_tokens);
- const oa = reg.get("openai", "gpt-4o").?;
- try testing.expectEqual(@as(?u64, 250), oa.input);
- try testing.expectEqual(@as(?u64, 1000), oa.output);
- try testing.expectEqual(@as(?u64, 125), oa.cache_read);
- // cache_write absent in source — stays unknown, NOT silently 0.
- try testing.expectEqual(@as(?u64, null), oa.cache_write);
+ const gpt = models.defs.get("openai", "gpt").?;
+ try testing.expectEqualStrings("gpt-4o", gpt.model);
+ try testing.expectEqual(ReasoningEffort.default, gpt.reasoning);
+ try testing.expectEqual(@as(?u32, null), gpt.max_tokens);
- const mini = reg.get("openai", "gpt-4o-mini").?;
- try testing.expectEqual(@as(?u64, 15), mini.input);
- try testing.expectEqual(@as(?u64, 60), mini.output);
- try testing.expectEqual(@as(?u64, null), mini.cache_read);
- try testing.expectEqual(@as(?u64, null), mini.cache_write);
+ // Pricing keyed on the wire model id.
+ const sonnet_price = models.pricing.get("anthropic", "claude-sonnet-4-20250514").?;
+ try testing.expectEqual(@as(?u64, 300), sonnet_price.input);
+ try testing.expectEqual(@as(?u64, 1500), sonnet_price.output);
}
-test "parseInto: integer values are accepted (user writes `3` not `3.0`)" {
- var reg = Registry.init(testing.allocator);
- defer reg.deinit();
+test "parseInto: omitted model defaults the wire name to the alias" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
const src =
\\[openai.gpt-4o]
- \\input = 3
- \\output = 15
+ \\input = 2.5
+ \\output = 10.0
;
- try parseInto(&reg, src);
- const p = reg.get("openai", "gpt-4o").?;
- try testing.expectEqual(@as(?u64, 300), p.input);
- try testing.expectEqual(@as(?u64, 1500), p.output);
+ try parseInto(&models, src);
+
+ const def = models.defs.get("openai", "gpt-4o").?;
+ try testing.expectEqualStrings("gpt-4o", def.model);
}
-test "parseInto: missing optional fields stay null (unknown, not zero)" {
- var reg = Registry.init(testing.allocator);
- defer reg.deinit();
+test "parseInto: pricing omissions stay null (unknown, not zero)" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
const src =
- \\[openai.gpt-4o]
+ \\[openai.gpt]
+ \\model = "gpt-4o"
\\input = 2.5
\\output = 10.0
;
- try parseInto(&reg, src);
- const p = reg.get("openai", "gpt-4o").?;
+ try parseInto(&models, src);
+ const p = models.pricing.get("openai", "gpt-4o").?;
try testing.expectEqual(@as(?u64, null), p.cache_read);
try testing.expectEqual(@as(?u64, null), p.cache_write);
}
-test "parseInto: explicit 0 is a known zero, distinct from omission" {
- // OpenAI doesn't charge for cache writes. Writing `cache_write = 0`
- // in the TOML must produce a known 0 — not null — so cost stays
- // computable on turns that report cache_write usage.
- var reg = Registry.init(testing.allocator);
- defer reg.deinit();
+test "parseInto: explicit cache_write = 0 is a known zero" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
const src =
- \\[openai.gpt-4o]
+ \\[openai.gpt]
+ \\model = "gpt-4o"
\\input = 2.5
\\output = 10.0
- \\cache_read = 1.25
\\cache_write = 0
;
- try parseInto(&reg, src);
- const p = reg.get("openai", "gpt-4o").?;
+ try parseInto(&models, src);
+ const p = models.pricing.get("openai", "gpt-4o").?;
try testing.expectEqual(@as(?u64, 0), p.cache_write);
}
test "parseInto: malformed TOML returns InvalidModelsToml" {
- var reg = Registry.init(testing.allocator);
- defer reg.deinit();
- try testing.expectError(error.InvalidModelsToml, parseInto(&reg, "this is not valid toml = ="));
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
+ try testing.expectError(error.InvalidModelsToml, parseInto(&models, "not valid = ="));
}
-test "loadFromPath: missing file returns empty registry, no error" {
+test "loadFromPath: missing file returns empty registries, no error" {
const io = testing.io;
- var reg = try loadFromPath(testing.allocator, io, "/nonexistent/path/models.toml");
- defer reg.deinit();
- try testing.expectEqual(@as(usize, 0), reg.count());
+ var models = try loadFromPath(testing.allocator, io, "/nonexistent/models.toml");
+ defer models.deinit();
+ try testing.expectEqual(@as(usize, 0), models.defs.count());
+ try testing.expectEqual(@as(usize, 0), models.pricing.count());
+}
+
+test "ModelRegistry.get: returns null for unknown alias" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
+ const src =
+ \\[anthropic.sonnet]
+ \\model = "claude-sonnet-4-20250514"
+ ;
+ try parseInto(&models, src);
+ try testing.expect(models.defs.get("anthropic", "opus") == null);
+ try testing.expect(models.defs.get("openai", "sonnet") == null);
}
test "configPath: XDG_CONFIG_HOME wins" {
diff --git a/src/panto_home.zig b/src/panto_home.zig
index 353e54b..e75d14b 100644
--- a/src/panto_home.zig
+++ b/src/panto_home.zig
@@ -30,10 +30,10 @@ pub const Layout = struct {
allocator: Allocator,
/// `$PANTO_HOME` itself.
home: []u8,
- /// `$PANTO_HOME/agent/` — the "system" extension/tool tree,
+ /// `$PANTO_HOME/agent/` — the "base" extension/tool tree,
/// populated at bootstrap from files embedded into the panto
/// binary. Searched after user/project layers for tools and
- /// extensions; project shadows user shadows system.
+ /// extensions; project shadows user shadows base.
agent_dir: []u8,
/// `$PANTO_HOME/rocks/lua-<lua_version>/` — the versioned tree.
tree: []u8,
diff --git a/src/subcommand.zig b/src/subcommand.zig
index f6c3058..c50429e 100644
--- a/src/subcommand.zig
+++ b/src/subcommand.zig
@@ -102,11 +102,17 @@ fn printHelp(io: Io) !void {
\\ panto lua [args...] Drop into the embedded Lua interpreter.
\\ panto help Show this message.
\\
+ \\Configuration (TOML, merged base → user → project):
+ \\ $XDG_DATA_HOME/panto/config.toml (base; auto-generated)
+ \\ $XDG_CONFIG_HOME/panto/config.toml (user)
+ \\ ./.panto/config.toml (project)
+ \\ Define providers under [providers.<name>], pick a default with
+ \\ [defaults] model = "<provider>:<alias>", and gate tools/extensions
+ \\ with [tools]/[extensions] allow/deny globs. Model aliases (wire name,
+ \\ reasoning, max_tokens, pricing) live in models.toml.
+ \\
\\Environment:
- \\ PANTO_API_STYLE "openai_chat" (default) or "anthropic_messages".
- \\ OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_REASONING
- \\ ANTHROPIC_API_KEY, ANTHROPIC_MODEL, ANTHROPIC_BASE_URL,
- \\ ANTHROPIC_API_VERSION, ANTHROPIC_MAX_TOKENS
+ \\ OPENAI_API_KEY, ANTHROPIC_API_KEY Consumed by the default providers.
\\ PANTO_SESSION_DIR Override the base sessions directory. Defaults to
\\ $XDG_DATA_HOME/panto/sessions or ~/.local/share/panto/sessions.
\\ PANTO_HOME Override the runtime/rocks tree location.