summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config_file.zig468
-rw-r--r--src/extension_loader.zig1181
-rw-r--r--src/glob.zig74
-rw-r--r--src/lua_runtime.zig352
-rw-r--r--src/luarocks_runtime.zig122
-rw-r--r--src/main.zig18
-rw-r--r--src/subcommand.zig70
7 files changed, 1288 insertions, 997 deletions
diff --git a/src/config_file.zig b/src/config_file.zig
index 5bd2a51..deca908 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -12,11 +12,17 @@
//! `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.
+//! higher-precedence layer overwrite wholesale.** Tables (notably `providers`)
+//! accumulate: a provider defined only at the base layer survives even when
+//! the project layer adds a different provider.
+//!
+//! **Exception — `[extensions]` allow/deny.** These are NOT clobbered across
+//! layers. Every layer's rules are retained, tagged with their layer, and
+//! resolved by "last matching rule wins" after sorting by
+//! `(layer, specificity, deny-last)` — see `Policy`. So a higher layer's rule
+//! beats a lower layer's, within a layer the more-specific pattern wins, and
+//! `deny` wins an exact tie. This lets a base layer carve one name out of an
+//! otherwise-denied group without a higher layer's unrelated rule erasing it.
//!
//! After merge, the document is resolved into a `Config`:
//!
@@ -40,9 +46,10 @@
//! falls back to the provider name). `extra_headers` (a table of string
//! headers) rides on the model request.
//! - `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.
+//! - `[extensions]` allow/deny globs across all layers become one sorted
+//! `Policy` rule list (see the merge-semantics note above). A pattern in
+//! both allow and deny is no longer an error — resolution is deterministic
+//! (deny wins the tie).
//!
//! Model *aliases* (the `<model_alias>` half of a model reference) are
//! resolved separately against `models.toml` — this module only validates
@@ -130,33 +137,126 @@ pub const ModelRef = struct {
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 Verdict = enum { allow, deny };
+
+/// One allow/deny glob rule, tagged with the config layer it came from and
+/// its precomputed specificity. Rules are kept from every layer (never
+/// clobbered) and resolved by "last matching rule wins" after sorting.
+pub const Rule = struct {
+ pattern: []const u8,
+ verdict: Verdict,
+ layer: u16,
+ spec: glob.Specificity,
+};
+
+/// Extension availability policy: an ordered list of allow/deny glob rules
+/// drawn from every config layer. The default is **allow** (an empty policy
+/// permits everything). A name's verdict is the LAST rule that matches it,
+/// where rules are sorted ascending by `(layer, specificity, allow-before-deny)`.
+/// So a higher layer's rule beats a lower layer's; within a layer the more
+/// specific pattern wins; at an exact tie `deny` wins. A pure whitelist is
+/// `deny = ["**"]` plus specific `allow` carve-outs.
pub const Policy = struct {
- allow: [][]const u8,
- deny: [][]const u8,
+ rules: []Rule = &.{},
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);
+ for (self.rules) |r| alloc.free(r.pattern);
+ alloc.free(self.rules);
}
- /// True if `name` is permitted under this policy.
+ /// True if `name` is permitted. Scans the sorted rule list and keeps the
+ /// verdict of the last matching rule; defaults to allow when nothing
+ /// matches.
pub fn permits(self: Policy, name: []const u8) bool {
- for (self.deny) |pat| {
- if (glob.match(pat, name)) return false;
+ var verdict: Verdict = .allow;
+ for (self.rules) |r| {
+ if (glob.match(r.pattern, name)) verdict = r.verdict;
}
- if (self.allow.len == 0) return true;
- for (self.allow) |pat| {
- if (glob.match(pat, name)) return true;
+ return verdict == .allow;
+ }
+};
+
+/// Ascending sort: `(layer, specificity, allow-before-deny)`. After sorting,
+/// the last rule that matches a name is the winner.
+fn ruleLessThan(_: void, a: Rule, b: Rule) bool {
+ if (a.layer != b.layer) return a.layer < b.layer;
+ switch (a.spec.order(b.spec)) {
+ .lt => return true,
+ .gt => return false,
+ .eq => {},
+ }
+ // allow (0) sorts before deny (1) so deny wins an exact tie.
+ return @intFromEnum(a.verdict) < @intFromEnum(b.verdict);
+}
+
+/// Append the `[extensions]` allow/deny rules from one layer's document to
+/// `out`, tagged with `layer`. Duplicating patterns is harmless — resolution
+/// is deterministic (deny wins ties).
+fn collectRules(
+ allocator: Allocator,
+ root: *const toml.Value,
+ layer: u16,
+ out: *std.ArrayList(Rule),
+) Error!void {
+ const tbl = root.get("extensions") orelse return;
+ if (tbl.* != .table) return;
+ inline for (.{ .{ "allow", Verdict.allow }, .{ "deny", Verdict.deny } }) |pair| {
+ const patterns = try stringArray(allocator, tbl, pair[0]);
+ defer allocator.free(patterns); // the slice; strings move into `out`
+ var i: usize = 0;
+ // On error, free only the patterns not yet moved into `out`; the
+ // moved ones (patterns[0..i]) are owned by `out` and freed by the caller.
+ errdefer for (patterns[i..]) |p| allocator.free(p);
+ while (i < patterns.len) : (i += 1) {
+ try out.append(allocator, .{
+ .pattern = patterns[i],
+ .verdict = pair[1],
+ .layer = layer,
+ .spec = glob.specificity(patterns[i]),
+ });
}
- return false;
}
+}
+
+/// Sort collected rules into resolution order and wrap in a `Policy`.
+fn buildPolicy(allocator: Allocator, rules: *std.ArrayList(Rule)) Policy {
+ std.mem.sort(Rule, rules.items, {}, ruleLessThan);
+ return .{ .rules = rules.toOwnedSlice(allocator) catch &.{} };
+}
+
+/// A config string (a `paths` entry or a `rocks` spec) tagged with the layer
+/// it came from, so the loader can assign it the right precedence.
+pub const LayeredStr = struct {
+ value: []const u8,
+ layer: u16,
};
+/// Append the string array `extensions.<key>` from one layer to `out`,
+/// tagged with `layer`. Used for `paths` and `rocks` (source lists that
+/// accumulate across layers rather than clobbering).
+fn collectStrings(
+ allocator: Allocator,
+ root: *const toml.Value,
+ key: []const u8,
+ layer: u16,
+ out: *std.ArrayList(LayeredStr),
+) Error!void {
+ const tbl = root.get("extensions") orelse return;
+ if (tbl.* != .table) return;
+ const values = try stringArray(allocator, tbl, key);
+ defer allocator.free(values);
+ var i: usize = 0;
+ errdefer for (values[i..]) |v| allocator.free(v);
+ while (i < values.len) : (i += 1) {
+ try out.append(allocator, .{ .value = values[i], .layer = layer });
+ }
+}
+
+fn freeLayeredStrs(allocator: Allocator, list: []LayeredStr) void {
+ for (list) |s| allocator.free(s.value);
+ allocator.free(list);
+}
+
/// Build a `panto.ProviderConfig` for a chosen `<provider>:<alias>` model
/// reference, combining the resolved provider (transport/auth) with the
/// model definition from `models.toml` (wire name + knobs).
@@ -246,8 +346,15 @@ pub const Config = struct {
/// `defaults.model` storage, owned. `default_model_ref` slices into it.
default_model: ?[]const u8,
default_model_ref: ?ModelRef,
- tools: Policy,
+ /// Extension availability policy (the `[extensions]` section), resolved
+ /// across all config layers. Governs every lua-authored capability.
extensions: Policy,
+ /// Extra filesystem dirs to scan for extensions (`extensions.paths`),
+ /// each tagged with the config layer that listed it.
+ ext_paths: []LayeredStr = &.{},
+ /// luarocks dependency strings to load as extension sources
+ /// (`extensions.rocks`), each tagged with its config layer.
+ ext_rocks: []LayeredStr = &.{},
/// `[compaction]` settings. `compaction_keep_verbatim` is the kept-
/// suffix token budget (null = use libpanto's default). `compaction_model`
/// is an owned `<provider>:<alias>` reference string for the optional
@@ -262,8 +369,9 @@ pub const Config = struct {
self.allocator.free(self.auths);
if (self.default_model) |m| self.allocator.free(m);
if (self.compaction_model) |m| self.allocator.free(m);
- self.tools.deinit(self.allocator);
self.extensions.deinit(self.allocator);
+ freeLayeredStrs(self.allocator, self.ext_paths);
+ freeLayeredStrs(self.allocator, self.ext_rocks);
// Frees every auth-config string, header array, resolved api key,
// and provider `extra_headers` in one shot.
self.auth_arena.deinit();
@@ -430,9 +538,51 @@ pub fn loadFromString(
) Error!Config {
const doc = parseDoc(allocator, source) catch return error.InvalidConfigToml;
defer doc.deinit();
- return resolve(allocator, environ_map, doc.root);
+
+ var srcs = try SourceLists.collect(allocator, &.{doc.root});
+ errdefer srcs.deinit(allocator);
+ const policy = buildPolicy(allocator, &srcs.rules);
+
+ return resolve(
+ allocator,
+ environ_map,
+ doc.root,
+ policy,
+ try srcs.paths.toOwnedSlice(allocator),
+ try srcs.rocks.toOwnedSlice(allocator),
+ );
}
+/// Accumulates the per-layer `[extensions]` rules and source lists during a
+/// layered load. `collect` gathers from a slice of already-parsed layer
+/// roots (lowest precedence first).
+const SourceLists = struct {
+ rules: std.ArrayList(Rule) = .empty,
+ paths: std.ArrayList(LayeredStr) = .empty,
+ rocks: std.ArrayList(LayeredStr) = .empty,
+
+ fn collect(allocator: Allocator, roots: []const *const toml.Value) Error!SourceLists {
+ var self: SourceLists = .{};
+ errdefer self.deinit(allocator);
+ for (roots, 0..) |root, i| {
+ const layer: u16 = @intCast(i);
+ try collectRules(allocator, root, layer, &self.rules);
+ try collectStrings(allocator, root, "paths", layer, &self.paths);
+ try collectStrings(allocator, root, "rocks", layer, &self.rocks);
+ }
+ return self;
+ }
+
+ fn deinit(self: *SourceLists, allocator: Allocator) void {
+ for (self.rules.items) |r| allocator.free(r.pattern);
+ self.rules.deinit(allocator);
+ for (self.paths.items) |s| allocator.free(s.value);
+ self.paths.deinit(allocator);
+ for (self.rocks.items) |s| allocator.free(s.value);
+ self.rocks.deinit(allocator);
+ }
+};
+
/// Load from explicit layer paths, lowest precedence first. Useful for
/// tests. Missing files are skipped.
pub fn loadFromPaths(
@@ -442,11 +592,16 @@ pub fn loadFromPaths(
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.
+ // never have to reason about which source layer a given Value came from —
+ // EXCEPT the `[extensions]` allow/deny rules, which must retain their
+ // layer of origin (they are collected separately, not merged/clobbered).
const merged = try tvalue.createDocument(allocator);
defer merged.deinit();
- for (paths) |path| {
+ var srcs: SourceLists = .{};
+ errdefer srcs.deinit(allocator);
+
+ for (paths, 0..) |path, i| {
const bytes = toml_layer.readFileAlloc(allocator, io, path) catch |err| switch (err) {
error.FileNotFound => continue,
error.ReadFailed => return error.ConfigReadFailed,
@@ -457,10 +612,24 @@ pub fn loadFromPaths(
const doc = parseDoc(allocator, bytes) catch return error.InvalidConfigToml;
defer doc.deinit();
+ // Capture this layer's policy rules + source lists before the
+ // clobbering merge (they accumulate across layers, not clobber).
+ const layer: u16 = @intCast(i);
+ try collectRules(allocator, doc.root, layer, &srcs.rules);
+ try collectStrings(allocator, doc.root, "paths", layer, &srcs.paths);
+ try collectStrings(allocator, doc.root, "rocks", layer, &srcs.rocks);
try toml_layer.mergeTable(merged.allocator(), merged.root, doc.root);
}
- return resolve(allocator, environ_map, merged.root);
+ const policy = buildPolicy(allocator, &srcs.rules);
+ return resolve(
+ allocator,
+ environ_map,
+ merged.root,
+ policy,
+ try srcs.paths.toOwnedSlice(allocator),
+ try srcs.rocks.toOwnedSlice(allocator),
+ );
}
// ===========================================================================
@@ -471,7 +640,16 @@ fn resolve(
allocator: Allocator,
environ_map: *const std.process.Environ.Map,
root: *const toml.Value,
+ /// Extension policy + source lists, pre-built from all layers by the
+ /// caller. Ownership transfers into the returned Config (or is freed on
+ /// the error path).
+ extensions: Policy,
+ ext_paths: []LayeredStr,
+ ext_rocks: []LayeredStr,
) Error!Config {
+ errdefer extensions.deinit(allocator);
+ errdefer freeLayeredStrs(allocator, ext_paths);
+ errdefer freeLayeredStrs(allocator, ext_rocks);
// Arena owning every auth-related allocation. Moved into the returned
// Config on success; deinit'd on any error path before then.
var auth_arena = std.heap.ArenaAllocator.init(allocator);
@@ -533,12 +711,6 @@ fn resolve(
providers.shrinkRetainingCapacity(w);
}
- // 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);
@@ -610,8 +782,9 @@ fn resolve(
.auth_arena = auth_arena,
.default_model = default_model,
.default_model_ref = default_model_ref,
- .tools = tools,
.extensions = extensions,
+ .ext_paths = ext_paths,
+ .ext_rocks = ext_rocks,
.compaction_keep_verbatim = compaction_keep_verbatim,
.compaction_model = compaction_model,
.compaction_model_ref = compaction_model_ref,
@@ -836,33 +1009,6 @@ fn tomlStr(val: *const toml.Value, key: []const u8) ?[]const u8 {
return v.asString();
}
-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 &.{};
@@ -921,6 +1067,36 @@ fn emptyEnv(a: Allocator) std.process.Environ.Map {
return std.process.Environ.Map.init(a);
}
+/// Test helper: resolve a single already-parsed document, building its
+/// `[extensions]` policy from that one layer (layer 0).
+fn resolveDoc(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ root: *const toml.Value,
+) Error!Config {
+ var srcs = try SourceLists.collect(allocator, &.{root});
+ errdefer srcs.deinit(allocator);
+ const policy = buildPolicy(allocator, &srcs.rules);
+ return resolve(
+ allocator,
+ environ_map,
+ root,
+ policy,
+ try srcs.paths.toOwnedSlice(allocator),
+ try srcs.rocks.toOwnedSlice(allocator),
+ );
+}
+
+/// Test helper: build a `Policy` from a `[extensions]` TOML fragment on one
+/// layer. Panics on parse error (test-only).
+fn policyFromToml(a: Allocator, src: []const u8) Policy {
+ const doc = parseDoc(a, src) catch unreachable;
+ defer doc.deinit();
+ var rules: std.ArrayList(Rule) = .empty;
+ collectRules(a, doc.root, 0, &rules) catch unreachable;
+ return buildPolicy(a, &rules);
+}
+
test "parseModelRef: splits provider and model" {
const ref = try parseModelRef("anthropic:claude-sonnet-4-6");
try testing.expectEqualStrings("anthropic", ref.provider);
@@ -934,28 +1110,107 @@ test "parseModelRef: rejects malformed refs" {
try testing.expectError(error.InvalidModelRef, parseModelRef("a:b:c"));
}
-test "Policy.permits: empty allow means allow-all minus deny" {
+test "Policy.permits: default allow; a deny blocks its matches" {
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 };
+ const policy = policyFromToml(a,
+ \\[extensions]
+ \\deny = ["std.shell"]
+ );
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" {
+test "Policy.permits: whitelist via deny=[**] plus allow carve-outs" {
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 = &.{} };
+ const policy = policyFromToml(a,
+ \\[extensions]
+ \\deny = ["**"]
+ \\allow = ["std.*"]
+ );
defer policy.deinit(a);
+ // std.* is more specific than ** → permitted; everything else denied.
try testing.expect(policy.permits("std.read"));
try testing.expect(!policy.permits("other.read"));
}
+test "Policy.permits: specific allow carves one name out of a broad deny" {
+ const a = testing.allocator;
+ const policy = policyFromToml(a,
+ \\[extensions]
+ \\deny = ["agent.*"]
+ \\allow = ["agent.rules"]
+ );
+ defer policy.deinit(a);
+
+ try testing.expect(policy.permits("agent.rules")); // allow more specific
+ try testing.expect(!policy.permits("agent.skills")); // only the deny matches
+}
+
+test "Policy.permits: deny beats allow at an exact tie" {
+ const a = testing.allocator;
+ const policy = policyFromToml(a,
+ \\[extensions]
+ \\allow = ["agent.rules"]
+ \\deny = ["agent.rules"]
+ );
+ defer policy.deinit(a);
+
+ try testing.expect(!policy.permits("agent.rules"));
+}
+
+test "Policy.permits: higher layer's broad rule beats lower layer's specific one" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var pb: [std.fs.max_path_bytes]u8 = undefined;
+ const tpn = try tmp.dir.realPath(testing.io, &pb);
+ const tp = pb[0..tpn];
+
+ // Base pins one specific name; user broadly denies the group.
+ const base = try std.fs.path.join(a, &.{ tp, "b.toml" });
+ defer a.free(base);
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.toml", .data =
+ \\[extensions]
+ \\allow = ["agent.subagents"]
+ });
+ const user = try std.fs.path.join(a, &.{ tp, "u.toml" });
+ defer a.free(user);
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "u.toml", .data =
+ \\[extensions]
+ \\deny = ["agent.*"]
+ });
+
+ var cfg = try loadFromPaths(a, testing.io, &env, &.{ base, user });
+ defer cfg.deinit();
+ // Higher (user) layer's broad deny wins over base's specific allow.
+ try testing.expect(!cfg.extensions.permits("agent.subagents"));
+}
+
+test "extensions.paths and rocks parse per layer" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[extensions]
+ \\paths = ["./dev/ext"]
+ \\rocks = ["panto-agent 1.4.2-1"]
+ ;
+ var cfg = try loadFromString(a, &env, src);
+ defer cfg.deinit();
+
+ try testing.expectEqual(@as(usize, 1), cfg.ext_paths.len);
+ try testing.expectEqualStrings("./dev/ext", cfg.ext_paths[0].value);
+ try testing.expectEqual(@as(usize, 1), cfg.ext_rocks.len);
+ try testing.expectEqualStrings("panto-agent 1.4.2-1", cfg.ext_rocks[0].value);
+}
+
/// Standard api_key provider+auth source for an anthropic provider whose key
/// comes from `ANTHROPIC_API_KEY`. Keeps the many model-knob tests terse.
const anthropic_env_src =
@@ -976,7 +1231,7 @@ test "resolve: api_key provider is dropped when its ${env} key is unset" {
const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
// Unresolved api_key ⇒ the provider disappears (export the key to get it
// back). The auth session itself still exists.
@@ -997,7 +1252,7 @@ test "resolve: api_key session resolves when env var is set" {
const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
try testing.expectEqual(@as(usize, 1), cfg.providers.len);
const p = cfg.provider("anthropic").?;
@@ -1017,7 +1272,7 @@ test "resolve: missing provider auth is an error" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- try testing.expectError(error.MissingProviderAuth, resolve(a, &env, doc.root));
+ try testing.expectError(error.MissingProviderAuth, resolveDoc(a, &env, doc.root));
}
test "resolve: provider naming an unknown auth session is an error" {
@@ -1032,7 +1287,7 @@ test "resolve: provider naming an unknown auth session is an error" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- try testing.expectError(error.UnknownProviderAuth, resolve(a, &env, doc.root));
+ try testing.expectError(error.UnknownProviderAuth, resolveDoc(a, &env, doc.root));
}
test "resolve: ${env:VAR} and ${sibling} substitution in auth values" {
@@ -1064,7 +1319,7 @@ test "resolve: ${env:VAR} and ${sibling} substitution in auth values" {
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
// env substitution resolves the literal key.
try testing.expectEqualStrings("sk-from-env", cfg.auth("k").?.resolved_api_key.?);
@@ -1085,7 +1340,7 @@ test "resolve: literal key resolves without substitution" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
try testing.expectEqualStrings("sk-literal", cfg.auth("k").?.resolved_api_key.?);
}
@@ -1100,7 +1355,7 @@ test "resolve: invalid auth type is an error" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- try testing.expectError(error.InvalidAuth, resolve(a, &env, doc.root));
+ try testing.expectError(error.InvalidAuth, resolveDoc(a, &env, doc.root));
}
test "resolve: oauth_device (token dialect) with exchange parses" {
@@ -1120,7 +1375,7 @@ test "resolve: oauth_device (token dialect) with exchange parses" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
const oauth = cfg.auth("github_copilot").?.config.oauth_device;
// type + dialect inferred/defaulted.
@@ -1147,7 +1402,7 @@ test "resolve: oauth_device codex dialect requires a poll url" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- try testing.expectError(error.InvalidAuth, resolve(a, &env, doc.root));
+ try testing.expectError(error.InvalidAuth, resolveDoc(a, &env, doc.root));
}
test "resolve: provider extra_headers flow into the built provider config" {
@@ -1170,7 +1425,7 @@ test "resolve: provider extra_headers flow into the built provider config" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
const p = cfg.provider("copilot").?;
@@ -1199,7 +1454,7 @@ test "resolve: provider model_catalog_name is captured" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
try testing.expectEqualStrings("github-copilot", cfg.provider("copilot").?.model_catalog_name.?);
@@ -1221,7 +1476,7 @@ test "resolve: openai_responses codex dialect maps to internal API style" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
try testing.expectEqual(APIStyle.openai_codex_responses, cfg.provider("codex").?.style);
@@ -1246,7 +1501,7 @@ test "resolve: internal openai_codex_responses style is not user-facing" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- try testing.expectError(error.InvalidProvider, resolve(a, &env, doc.root));
+ try testing.expectError(error.InvalidProvider, resolveDoc(a, &env, doc.root));
}
test "resolve: prompt_cache defaults true and is parsed when set" {
@@ -1273,7 +1528,7 @@ test "resolve: prompt_cache defaults true and is parsed when set" {
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
// Absent => library default (true).
@@ -1326,7 +1581,7 @@ test "buildProviderConfig: prompt_cache flows from provider into anthropic confi
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
var defs = models_toml.ModelRegistry.init(a);
@@ -1349,23 +1604,26 @@ test "resolve: unknown default-model provider is an error" {
const doc = try parseDoc(a, src);
defer doc.deinit();
- try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root));
+ try testing.expectError(error.UnknownDefaultProvider, resolveDoc(a, &env, doc.root));
}
-test "resolvePolicy: pattern in both allow and deny is a conflict" {
+test "extensions policy: a pattern in both allow and deny resolves to deny" {
const a = testing.allocator;
var env = emptyEnv(a);
defer env.deinit();
const src =
- \\[tools]
+ \\[extensions]
\\allow = ["std.*"]
\\deny = ["std.*"]
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- try testing.expectError(error.PolicyConflict, resolve(a, &env, doc.root));
+ var cfg = try resolveDoc(a, &env, doc.root);
+ defer cfg.deinit();
+ // No longer an error — deny wins the exact tie.
+ try testing.expect(!cfg.extensions.permits("std.read"));
}
test "loadFromPaths: layers merge with tables accumulating and scalars overriding" {
@@ -1394,8 +1652,8 @@ test "loadFromPaths: layers merge with tables accumulating and scalars overridin
\\type = "api_key"
\\key = "sys-key"
\\
- \\[tools]
- \\allow = ["std.*"]
+ \\[extensions]
+ \\deny = ["std.*"]
});
// Project layer: adds an openai provider (table accumulates), and
@@ -1432,8 +1690,8 @@ test "loadFromPaths: layers merge with tables accumulating and scalars overridin
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"));
+ // The base layer's extensions.deny survives (project adds no override).
+ try testing.expect(!cfg.extensions.permits("std.read"));
}
test "loadFromPaths: local layer overrides project layer" {
@@ -1492,7 +1750,7 @@ test "buildProviderConfig: anthropic ref pulls knobs from model defs" {
const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
var models = models_toml.ModelRegistry.init(a);
@@ -1539,7 +1797,7 @@ test "buildProviderConfig: missing alias uses the alias verbatim as wire model"
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
var models = models_toml.ModelRegistry.init(a);
@@ -1560,7 +1818,7 @@ test "buildProviderConfig: anthropic adaptive thinking threaded from model def"
const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
var models = models_toml.ModelRegistry.init(a);
@@ -1593,7 +1851,7 @@ test "buildProviderConfig: anthropic missing alias falls back to struct defaults
const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
var models = models_toml.ModelRegistry.init(a);
@@ -1615,7 +1873,7 @@ test "buildProviderConfig: unknown provider errors" {
defer env.deinit();
const empty = try parseDoc(a, "");
defer empty.deinit();
- var cfg = try resolve(a, &env, empty.root);
+ var cfg = try resolveDoc(a, &env, empty.root);
defer cfg.deinit();
var models = models_toml.ModelRegistry.init(a);
defer models.deinit();
@@ -1643,7 +1901,7 @@ test "selectModel: default_model wins; single-provider fallback otherwise" {
;
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
var models = models_toml.ModelRegistry.init(a);
@@ -1664,7 +1922,7 @@ test "selectModel: no default and no providers errors" {
defer env.deinit();
const empty = try parseDoc(a, "");
defer empty.deinit();
- var cfg = try resolve(a, &env, empty.root);
+ var cfg = try resolveDoc(a, &env, empty.root);
defer cfg.deinit();
var models = models_toml.ModelRegistry.init(a);
defer models.deinit();
@@ -1701,7 +1959,7 @@ test "resolve: [compaction] keep_verbatim and model parse" {
const doc = try parseDoc(a, src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
try testing.expectEqual(@as(?u32, 12345), cfg.compaction_keep_verbatim);
try testing.expectEqualStrings("anthropic:haiku", cfg.compaction_model.?);
@@ -1718,7 +1976,7 @@ test "resolve: [compaction] absent leaves defaults null" {
const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
- var cfg = try resolve(a, &env, doc.root);
+ var cfg = try resolveDoc(a, &env, doc.root);
defer cfg.deinit();
try testing.expect(cfg.compaction_keep_verbatim == null);
try testing.expect(cfg.compaction_model == null);
@@ -1739,5 +1997,5 @@ test "resolve: [compaction] model naming an unresolved provider errors" {
const doc = try parseDoc(a, src);
defer doc.deinit();
- try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root));
+ try testing.expectError(error.UnknownDefaultProvider, resolveDoc(a, &env, doc.root));
}
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
index 87eb609..4620a2e 100644
--- a/src/extension_loader.zig
+++ b/src/extension_loader.zig
@@ -1,45 +1,37 @@
-//! Extension and tool discovery: walk well-known directories, locate Lua
-//! files, and load each into a long-lived `LuaRuntime`.
+//! Extension discovery: walk well-known directories (plus any extra
+//! `paths` and installed `rocks`), evaluate each Lua source to learn what
+//! extension *entries* it declares, then activate the surviving ones into a
+//! long-lived `LuaRuntime`.
//!
-//! Two parallel namespaces are scanned, each at three scopes — base,
-//! user, and project. Project shadows user shadows base.
+//! Sources, in precedence order. Cross-layer: project > user > base.
+//! Within a layer: `rocks < paths < dir` (the canonical dir is most
+//! authoritative locally, a rock least). The scanned dirs are:
//!
-//! Extensions (full-featured; call `panto.ext.register_tool` from a script
-//! that may register many tools):
-//! 1. `<data home>/agent/extensions/` ("base")
-//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user")
-//! 3. `./.panto/extensions/` ("project")
+//! <data home>/agent/{extensions,tools}/ ("base")
+//! ${XDG_CONFIG_HOME:-$HOME/.config}/panto/{extensions,tools}/ ("user")
+//! ./.panto/{extensions,tools}/ ("project")
//!
-//! Tools (ergonomic single-tool form; the script returns one table
-//! shaped like the argument to `panto.ext.register_tool`):
-//! 1. `<data home>/agent/tools/` ("base")
-//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user")
-//! 3. `./.panto/tools/` ("project")
+//! The `base` layer is staged at bootstrap from files embedded into the
+//! binary (see `build/gen_agent_embed.zig`). `extensions/` and `tools/` are
+//! no longer distinct namespaces — both just contribute entries.
//!
-//! 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
-//! from the user or project layer.
+//! Layout per directory:
+//! - `<file>.lua` -- single-file source.
+//! - `<dir>/init.lua` -- directory source; the directory is added to
+//! `package.path` so it can `require` siblings.
//!
-//! Layout per directory (identical for extensions and tools):
-//! - `<name>.lua` -- single-file entry; the logical name is the
-//! basename without the `.lua` suffix.
-//! - `<name>/init.lua` -- directory entry; the logical name is the
-//! directory name. The directory is added to
-//! the script's `package.path` so it can
-//! `require` sibling Lua files.
+//! Each source is eval'd (side-effect-free) and must return an *entry*
+//! `{ name, activate }`, the sugar tool form `{ name, handler, schema, ... }`,
+//! or a list of those. Identity is the declared `name`, not the filename.
//!
-//! Conflict rules:
-//! - Within one directory, two entries with the same logical name
-//! are an error.
-//! - 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
-//! are still an error.
+//! Resolution (two passes):
+//! 1. Availability — eval every source, collect `name → entry`. Later
+//! (higher-precedence) source wins for the same name; same-precedence
+//! duplicate is an error.
+//! 2. Activation — for every permitted name (per the `[extensions]`
+//! policy), call `entry.activate()`.
//!
-//! Symlinks: followed normally. Dotfiles: skipped.
+//! Symlinks: followed normally. Dotfiles and `_`-prefixed files: skipped.
const std = @import("std");
const panto = @import("panto");
@@ -49,224 +41,233 @@ const config_file = @import("config_file.zig");
const Allocator = std.mem.Allocator;
const Io = std.Io;
const LuaRuntime = lua_runtime.LuaRuntime;
+const Entry = LuaRuntime.Entry;
-/// 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,
+pub const Layer = enum(u8) { base = 0, user = 1, project = 2 };
+
+/// Within-layer origin, ordered least → most authoritative.
+pub const Origin = enum(u8) { rocks = 0, paths = 1, dir = 2 };
+
+/// Where a source came from, and thus its precedence. Higher `rank()` wins
+/// when two sources declare the same entry name.
+pub const Source = struct {
+ layer: Layer,
+ origin: Origin,
+
+ pub fn rank(self: Source) u8 {
+ return (@as(u8, @intFromEnum(self.layer)) << 2) | @intFromEnum(self.origin);
+ }
+
+ pub fn label(self: Source) []const u8 {
+ return switch (self.layer) {
+ .base => "base",
+ .user => "user",
+ .project => "project",
+ };
+ }
};
-fn policyPermits(p: ?*const config_file.Policy, name: []const u8) bool {
- const pol = p orelse return true;
- return pol.permits(name);
-}
+/// One directory to scan, tagged with its source precedence.
+pub const ScanDir = struct {
+ path: []const u8,
+ source: Source,
+};
-/// 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);
-}
+/// An installed rock to load as an extension source: the Lua module name to
+/// `require`, tagged with its source precedence.
+pub const RockSource = struct {
+ module: []const u8,
+ source: Source,
+};
-/// A discovered extension or tool before loading. Owns its strings.
+/// A discovered Lua source file before evaluation. Owns its strings.
const Found = struct {
- /// Logical name (basename without `.lua`, or directory name).
- name: []u8,
- /// Absolute path to the Lua script to execute.
script_path: []u8,
- /// For directory-style entries, the directory containing the script.
+ /// For directory-style entries, the directory added to `package.path`.
package_root: ?[]u8,
- /// Which search-path source this came from.
source: Source,
- /// Whether this is a full extension or a single-tool script.
- kind: Kind,
pub fn deinit(self: *Found, allocator: Allocator) void {
- allocator.free(self.name);
allocator.free(self.script_path);
if (self.package_root) |p| allocator.free(p);
}
};
-pub const Source = enum {
- /// Staged at bootstrap from files embedded in the panto binary.
- /// Lowest priority — shadowed by user and project.
- base,
- user,
- project,
-
- pub fn label(self: Source) []const u8 {
- return switch (self) {
- .base => "base",
- .user => "user",
- .project => "project",
- };
- }
+/// A runtime `Entry` paired with the source it came from, for shadowing and
+/// policy resolution.
+const Candidate = struct {
+ entry: Entry,
+ source: Source,
+ script_path: []const u8, // borrowed from a `Found`, for diagnostics
};
-pub const Kind = enum {
- extension,
- tool,
+fn policyPermits(p: ?*const config_file.Policy, name: []const u8) bool {
+ const pol = p orelse return true;
+ return pol.permits(name);
+}
- pub fn label(self: Kind) []const u8 {
- return switch (self) {
- .extension => "extension",
- .tool => "tool",
- };
- }
-};
+/// Log at `err` in production but `warn` under test — the Zig test runner
+/// fails any test that logs an error, and several tests deliberately
+/// exercise error paths.
+fn logConflict(comptime fmt: []const u8, args: anytype) void {
+ if (@import("builtin").is_test) std.log.warn(fmt, args) else std.log.err(fmt, args);
+}
-/// Discover and load every extension and tool found in the standard
-/// paths into `runtime`. Returns the number of registered tools added
-/// to the runtime by this call.
-///
-/// `base_agent_dir`, when non-null, is the path under which embedded
-/// base tools/extensions have been staged — typically
-/// `<data home>/agent/`. Pass `null` to skip the base layer entirely
-/// (mostly useful for tests).
+/// Discover and load every extension into `runtime`, returning the number of
+/// registered tools this call added.
///
-/// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The
-/// project directories are always `cwd()/.panto/{extensions,tools}`.
+/// `base_agent_dir`, when non-null, is where embedded base sources have been
+/// staged (typically `<data home>/agent/`); pass `null` to skip base.
+/// `environ_map` is consulted for `HOME`/`XDG_CONFIG_HOME`; project dirs are
+/// `cwd()/.panto/{extensions,tools}`.
pub fn discoverAndLoad(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
base_agent_dir: ?[]const u8,
runtime: *LuaRuntime,
- policies: Policies,
+ policy: ?*const config_file.Policy,
+ /// Extra dirs from `extensions.paths`, each tagged with its config layer.
+ extra_paths: []const config_file.LayeredStr,
+ /// Installed rocks from `extensions.rocks` (raw dependency specs), each
+ /// tagged with its config layer. The Lua module `require`d is the spec's
+ /// first whitespace-delimited token (the rock name). Installation happens
+ /// upstream (see `main`); this only loads already-installed modules.
+ rocks: []const config_file.LayeredStr,
) !usize {
- 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 (base_agent_dir) |d|
- try std.fs.path.join(allocator, &.{ d, kindSubdir(.tool) })
- else
- null;
- defer if (sys_tool) |d| allocator.free(d);
+ var dirs: std.array_list.Managed(ScanDir) = .init(allocator);
+ defer {
+ for (dirs.items) |d| allocator.free(d.path);
+ dirs.deinit();
+ }
- const user_ext = try userKindDir(allocator, environ_map, .extension);
- defer if (user_ext) |d| allocator.free(d);
- const user_tool = try userKindDir(allocator, environ_map, .tool);
- defer if (user_tool) |d| allocator.free(d);
+ const cwd = try std.process.currentPathAlloc(io, allocator);
+ defer allocator.free(cwd);
- const project_ext = try projectKindDir(allocator, io, .extension);
- defer allocator.free(project_ext);
- const project_tool = try projectKindDir(allocator, io, .tool);
- defer allocator.free(project_tool);
+ // base: <data home>/agent/{extensions,tools}
+ if (base_agent_dir) |d| {
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ d, "extensions" }), .source = .{ .layer = .base, .origin = .dir } });
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ d, "tools" }), .source = .{ .layer = .base, .origin = .dir } });
+ }
+ // user: $XDG_CONFIG_HOME/panto/{extensions,tools}
+ if (try userConfigDir(allocator, environ_map)) |base| {
+ defer allocator.free(base);
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ base, "extensions" }), .source = .{ .layer = .user, .origin = .dir } });
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ base, "tools" }), .source = .{ .layer = .user, .origin = .dir } });
+ }
+ // project: cwd/.panto/{extensions,tools}
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ cwd, ".panto", "extensions" }), .source = .{ .layer = .project, .origin = .dir } });
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ cwd, ".panto", "tools" }), .source = .{ .layer = .project, .origin = .dir } });
- return loadFromDirs(
- allocator,
- io,
- runtime,
- .{
- .base_extensions = sys_ext,
- .user_extensions = user_ext,
- .project_extensions = project_ext,
- .base_tools = sys_tool,
- .user_tools = user_tool,
- .project_tools = project_tool,
- },
- policies,
- );
+ // extensions.paths: extra dirs at their config layer's precedence,
+ // origin=paths (below the canonical dir, above rocks). Relative paths
+ // resolve against cwd.
+ for (extra_paths) |p| {
+ const path = if (std.fs.path.isAbsolute(p.value))
+ try allocator.dupe(u8, p.value)
+ else
+ try std.fs.path.join(allocator, &.{ cwd, p.value });
+ try dirs.append(.{ .path = path, .source = .{ .layer = configLayer(p.layer), .origin = .paths } });
+ }
+
+ // extensions.rocks: require each rock's module (its name = first token).
+ var rock_srcs: std.array_list.Managed(RockSource) = .init(allocator);
+ defer rock_srcs.deinit();
+ for (rocks) |r| {
+ var it = std.mem.tokenizeAny(u8, r.value, " \t");
+ const module = it.next() orelse continue;
+ try rock_srcs.append(.{ .module = module, .source = .{ .layer = configLayer(r.layer), .origin = .rocks } });
+ }
+
+ return loadFromDirs(allocator, io, runtime, dirs.items, rock_srcs.items, policy);
}
-/// Set of search paths consumed by `loadFromDirs`. Any field may be
-/// null; missing directories on disk are silently skipped.
-///
-/// Scan order is base → user → project. `applyShadowing` keeps the
-/// *last* occurrence of each (kind, name), so project entries win,
-/// then user, then base.
-pub const DirSet = struct {
- base_extensions: ?[]const u8 = null,
- user_extensions: ?[]const u8 = null,
- project_extensions: ?[]const u8 = null,
- base_tools: ?[]const u8 = null,
- user_tools: ?[]const u8 = null,
- project_tools: ?[]const u8 = null,
-};
+/// Map a config layer index (base=0, user=1, project=2, local=3) to a loader
+/// precedence layer. The `local` layer shares `project` precedence.
+fn configLayer(layer: u16) Layer {
+ return switch (layer) {
+ 0 => .base,
+ 1 => .user,
+ else => .project,
+ };
+}
-/// Lower-level entry point: load from explicit user/project paths.
-/// Either path may be null; missing directories are silently skipped.
+/// Lower-level entry point: scan an explicit list of dirs (each tagged with
+/// its source precedence), then eval → shadow → filter → activate.
pub fn loadFromDirs(
allocator: Allocator,
io: Io,
runtime: *LuaRuntime,
- dirs: DirSet,
- policies: Policies,
+ dirs: []const ScanDir,
+ rocks: []const RockSource,
+ policy: ?*const config_file.Policy,
) !usize {
var found: std.array_list.Managed(Found) = .init(allocator);
defer {
for (found.items) |*f| f.deinit(allocator);
found.deinit();
}
+ for (dirs) |d| try scanDir(allocator, io, d.path, d.source, &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.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();
+ // ---- Pass 1: eval every source, collect candidate entries. ----
+ var cands: std.array_list.Managed(Candidate) = .init(allocator);
+ defer {
+ // Any candidate still here at scope exit was neither activated nor
+ // dropped (an error path); reclaim its Lua ref + name.
+ for (cands.items) |cnd| runtime.dropEntry(cnd.entry);
+ cands.deinit();
+ }
+ for (found.items) |f| {
+ var entries: std.array_list.Managed(Entry) = .init(allocator);
+ defer entries.deinit();
+ try runtime.evalEntries(f.script_path, f.package_root, &entries);
+ for (entries.items) |e| {
+ try cands.append(.{ .entry = e, .source = f.source, .script_path = f.script_path });
}
- 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);
- }
+ }
+ // Rocks: `require` each installed module. A rock that fails to load
+ // (not installed, bad shape) is logged and skipped rather than aborting
+ // startup — one bad optional rock should not kill the REPL.
+ for (rocks) |r| {
+ var entries: std.array_list.Managed(Entry) = .init(allocator);
+ defer entries.deinit();
+ runtime.evalEntriesFromModule(r.module, &entries) catch |err| {
+ logConflict("rock '{s}' failed to load: {t}", .{ r.module, err });
+ for (entries.items) |e| runtime.dropEntry(e);
+ continue;
+ };
+ for (entries.items) |e| {
+ try cands.append(.{ .entry = e, .source = r.source, .script_path = r.module });
}
- found.clearRetainingCapacity();
- try found.appendSlice(keep.items);
- keep.deinit();
}
+ // ---- Shadowing: keep the highest-precedence entry per name. ----
+ try applyShadowing(allocator, runtime, &cands);
+
+ // ---- Pass 2: activate permitted survivors. ----
const before = runtime.toolCount();
- for (found.items) |f| {
- const load_result = switch (f.kind) {
- .extension => runtime.loadExtension(f.script_path, f.package_root),
- .tool => runtime.loadTool(f.script_path, f.package_root),
- };
- load_result catch |err| {
- if (@import("builtin").is_test) {
- std.log.warn(
- "{s} '{s}' ({s}: {s}) failed to load: {t}",
- .{ f.kind.label(), f.name, f.source.label(), f.script_path, err },
- );
- } else {
- std.log.err(
- "{s} '{s}' ({s}: {s}) failed to load: {t}",
- .{ f.kind.label(), f.name, f.source.label(), f.script_path, err },
- );
- }
+ var i: usize = 0;
+ while (i < cands.items.len) : (i += 1) {
+ const cnd = cands.items[i];
+ if (!policyPermits(policy, cnd.entry.name)) {
+ std.log.debug("extension: '{s}' denied by policy", .{cnd.entry.name});
+ runtime.dropEntry(cnd.entry);
+ continue;
+ }
+ runtime.activateEntry(cnd.entry) catch |err| {
+ logConflict(
+ "extension '{s}' ({s}: {s}) failed to activate: {t}",
+ .{ cnd.entry.name, cnd.source.label(), cnd.script_path, err },
+ );
+ // activateEntry consumed the entry already. Drop the rest.
+ var j = i + 1;
+ while (j < cands.items.len) : (j += 1) runtime.dropEntry(cands.items[j].entry);
+ cands.clearRetainingCapacity();
return err;
};
- std.log.debug(
- "{s}: loaded '{s}' ({s})",
- .{ 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});
+ std.log.debug("extension: activated '{s}' ({s})", .{ cnd.entry.name, cnd.source.label() });
}
+ cands.clearRetainingCapacity();
return runtime.toolCount() - before;
}
@@ -275,34 +276,17 @@ pub fn loadFromDirs(
// Path resolution
// ---------------------------------------------------------------------------
-fn kindSubdir(kind: Kind) []const u8 {
- return switch (kind) {
- .extension => "extensions",
- .tool => "tools",
- };
-}
-
-fn userKindDir(
- allocator: Allocator,
- environ_map: *const std.process.Environ.Map,
- kind: Kind,
-) !?[]u8 {
- const sub = kindSubdir(kind);
+/// `${XDG_CONFIG_HOME:-$HOME/.config}/panto`, or null if neither is set.
+fn userConfigDir(allocator: Allocator, environ_map: *const std.process.Environ.Map) !?[]u8 {
if (environ_map.get("XDG_CONFIG_HOME")) |xdg| {
- return try std.fs.path.join(allocator, &.{ xdg, "panto", sub });
+ return try std.fs.path.join(allocator, &.{ xdg, "panto" });
}
if (environ_map.get("HOME")) |home| {
- return try std.fs.path.join(allocator, &.{ home, ".config", "panto", sub });
+ return try std.fs.path.join(allocator, &.{ home, ".config", "panto" });
}
return null;
}
-fn projectKindDir(allocator: Allocator, io: Io, kind: Kind) ![]u8 {
- const cwd = try std.process.currentPathAlloc(io, allocator);
- defer allocator.free(cwd);
- return try std.fs.path.join(allocator, &.{ cwd, ".panto", kindSubdir(kind) });
-}
-
// ---------------------------------------------------------------------------
// Directory scanning
// ---------------------------------------------------------------------------
@@ -312,7 +296,6 @@ fn scanDir(
io: Io,
dir_path: []const u8,
source: Source,
- kind: Kind,
out: *std.array_list.Managed(Found),
) !void {
var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch |err| switch (err) {
@@ -321,44 +304,16 @@ fn scanDir(
};
defer dir.close(io);
- var local_names: std.StringHashMap(void) = .init(allocator);
- defer {
- var it = local_names.keyIterator();
- while (it.next()) |k| allocator.free(k.*);
- local_names.deinit();
- }
-
var iter = dir.iterate();
while (try iter.next(io)) |entry| {
if (entry.name.len == 0 or entry.name[0] == '.') continue;
const maybe_found: ?Found = switch (entry.kind) {
- .file, .sym_link => try classifyFile(allocator, dir_path, entry.name, source, kind),
- .directory => try classifyDirectory(allocator, io, dir, dir_path, entry.name, source, kind),
+ .file, .sym_link => try classifyFile(allocator, dir_path, entry.name, source),
+ .directory => try classifyDirectory(allocator, io, dir, dir_path, entry.name, source),
else => null,
};
- const f = maybe_found orelse continue;
-
- const gop = try local_names.getOrPut(f.name);
- if (gop.found_existing) {
- if (@import("builtin").is_test) {
- std.log.warn(
- "{s} name '{s}' is provided by multiple entries in {s}",
- .{ kind.label(), f.name, dir_path },
- );
- } else {
- std.log.err(
- "{s} name '{s}' is provided by multiple entries in {s}",
- .{ kind.label(), f.name, dir_path },
- );
- }
- var dup = f;
- dup.deinit(allocator);
- return error.DuplicateExtensionInDirectory;
- }
- gop.key_ptr.* = try allocator.dupe(u8, f.name);
-
- try out.append(f);
+ if (maybe_found) |f| try out.append(f);
}
}
@@ -367,7 +322,6 @@ fn classifyFile(
dir_path: []const u8,
entry_name: []const u8,
source: Source,
- kind: Kind,
) !?Found {
if (!std.mem.endsWith(u8, entry_name, ".lua")) return null;
const base = entry_name[0 .. entry_name.len - ".lua".len];
@@ -376,18 +330,10 @@ fn classifyFile(
const script_path = try std.fs.path.join(allocator, &.{ dir_path, entry_name });
errdefer allocator.free(script_path);
- const name = try allocator.dupe(u8, base);
- errdefer allocator.free(name);
const package_root = try allocator.dupe(u8, dir_path);
errdefer allocator.free(package_root);
- return Found{
- .name = name,
- .script_path = script_path,
- .package_root = package_root,
- .source = source,
- .kind = kind,
- };
+ return Found{ .script_path = script_path, .package_root = package_root, .source = source };
}
fn classifyDirectory(
@@ -397,93 +343,75 @@ fn classifyDirectory(
dir_path: []const u8,
entry_name: []const u8,
source: Source,
- kind: Kind,
) !?Found {
var sub = parent.openDir(io, entry_name, .{}) catch return null;
defer sub.close(io);
- sub.access(io, "init.lua", .{}) catch |err| switch (err) {
- error.FileNotFound => return null,
- else => return null,
- };
+ sub.access(io, "init.lua", .{}) catch return null;
const package_root = try std.fs.path.join(allocator, &.{ dir_path, entry_name });
errdefer allocator.free(package_root);
const script_path = try std.fs.path.join(allocator, &.{ package_root, "init.lua" });
errdefer allocator.free(script_path);
- const name = try allocator.dupe(u8, entry_name);
- errdefer allocator.free(name);
- return Found{
- .name = name,
- .script_path = script_path,
- .package_root = package_root,
- .source = source,
- .kind = kind,
- };
+ return Found{ .script_path = script_path, .package_root = package_root, .source = source };
}
// ---------------------------------------------------------------------------
// Shadowing
// ---------------------------------------------------------------------------
-/// Shadowing key combines (kind, name) so a tool named `foo` does not
-/// shadow an extension named `foo` (or vice versa). Tool-name collisions
-/// across these are still caught later by the runtime/registry.
-const ShadowKey = struct {
- kind: Kind,
- name: []const u8,
-};
-
-const ShadowKeyCtx = struct {
- pub fn hash(_: ShadowKeyCtx, k: ShadowKey) u64 {
- var hasher = std.hash.Wyhash.init(0);
- hasher.update(&[_]u8{@intFromEnum(k.kind)});
- hasher.update(k.name);
- return hasher.final();
- }
- pub fn eql(_: ShadowKeyCtx, a: ShadowKey, b: ShadowKey) bool {
- return a.kind == b.kind and std.mem.eql(u8, a.name, b.name);
- }
-};
+/// Keep the highest-precedence candidate per declared name; drop the rest
+/// (their Lua refs are reclaimed). Two candidates with the same name AND the
+/// same source precedence are an ambiguous duplicate — an error.
+fn applyShadowing(
+ allocator: Allocator,
+ runtime: *LuaRuntime,
+ cands: *std.array_list.Managed(Candidate),
+) !void {
+ // name -> index of current winner in `cands`.
+ var winners: std.StringHashMap(usize) = .init(allocator);
+ defer winners.deinit();
-fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void {
- const keep = try allocator.alloc(bool, list.items.len);
+ const keep = try allocator.alloc(bool, cands.items.len);
defer allocator.free(keep);
- @memset(keep, false);
+ @memset(keep, true);
- {
- var latest: std.HashMap(ShadowKey, usize, ShadowKeyCtx, std.hash_map.default_max_load_percentage) = .init(allocator);
- defer latest.deinit();
-
- for (list.items, 0..) |f, i| {
- try latest.put(.{ .kind = f.kind, .name = f.name }, i);
+ for (cands.items, 0..) |cnd, i| {
+ const gop = try winners.getOrPut(cnd.entry.name);
+ if (!gop.found_existing) {
+ gop.value_ptr.* = i;
+ continue;
}
-
- for (list.items, 0..) |f, i| {
- const winner = latest.get(.{ .kind = f.kind, .name = f.name }).?;
- if (winner == i) {
- keep[i] = true;
- } else {
- std.log.debug(
- "{s}: '{s}' from {s} shadowed by {s}",
- .{ f.kind.label(), f.name, f.source.label(), list.items[winner].source.label() },
- );
- }
+ const prev = gop.value_ptr.*;
+ const prev_rank = cands.items[prev].source.rank();
+ const cur_rank = cnd.source.rank();
+ if (cur_rank == prev_rank) {
+ logConflict(
+ "extension name '{s}' declared by two same-precedence sources: {s} and {s}",
+ .{ cnd.entry.name, cands.items[prev].script_path, cnd.script_path },
+ );
+ return error.DuplicateExtensionName;
+ } else if (cur_rank > prev_rank) {
+ keep[prev] = false;
+ std.log.debug("extension: '{s}' from {s} shadowed by {s}", .{ cnd.entry.name, cands.items[prev].source.label(), cnd.source.label() });
+ gop.value_ptr.* = i;
+ } else {
+ keep[i] = false;
+ std.log.debug("extension: '{s}' from {s} shadowed by {s}", .{ cnd.entry.name, cnd.source.label(), cands.items[prev].source.label() });
}
}
var write: usize = 0;
- for (list.items, keep) |f, k| {
+ for (cands.items, keep) |cnd, k| {
if (k) {
- list.items[write] = f;
+ cands.items[write] = cnd;
write += 1;
} else {
- var dropped = f;
- dropped.deinit(allocator);
+ runtime.dropEntry(cnd.entry);
}
}
- list.shrinkRetainingCapacity(write);
+ cands.shrinkRetainingCapacity(write);
}
// ---------------------------------------------------------------------------
@@ -521,543 +449,264 @@ fn makeDir(dir: Io.Dir, sub_path: []const u8) !void {
try dir.createDirPath(testing.io, sub_path);
}
-test "scanDir picks up single-file and directory-style extensions" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "ext_root");
- try makeDir(tmp.dir, "ext_root/beta");
- try writeFile(tmp.dir, "ext_root/alpha.lua", "-- alpha\n");
- try writeFile(tmp.dir, "ext_root/_helper.lua", "-- helper module\n");
- try writeFile(tmp.dir, "ext_root/beta/init.lua", "-- beta init\n");
- try writeFile(tmp.dir, "ext_root/beta/helper.lua", "-- helper\n");
- try writeFile(tmp.dir, "ext_root/.ignored.lua", "-- hidden\n");
- try writeFile(tmp.dir, "ext_root/readme.txt", "noise\n");
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const ext_root_len = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf);
- const ext_root = path_buf[0..ext_root_len];
-
- var list: std.array_list.Managed(Found) = .init(testing.allocator);
- defer {
- for (list.items) |*f| f.deinit(testing.allocator);
- list.deinit();
- }
- try scanDir(testing.allocator, testing.io, ext_root, .user, .extension, &list);
-
- try testing.expectEqual(@as(usize, 2), list.items.len);
-
- std.mem.sort(Found, list.items, {}, struct {
- fn lt(_: void, a: Found, b: Found) bool {
- return std.mem.lessThan(u8, a.name, b.name);
- }
- }.lt);
-
- try testing.expectEqualStrings("alpha", list.items[0].name);
- try testing.expect(list.items[0].package_root != null);
- try testing.expect(std.mem.endsWith(u8, list.items[0].script_path, "alpha.lua"));
-
- try testing.expectEqualStrings("beta", list.items[1].name);
- try testing.expect(list.items[1].package_root != null);
- try testing.expect(std.mem.endsWith(u8, list.items[1].script_path, "init.lua"));
-}
-
-test "duplicate name in same directory is an error" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "ext_root/foo");
- try writeFile(tmp.dir, "ext_root/foo.lua", "-- single\n");
- try writeFile(tmp.dir, "ext_root/foo/init.lua", "-- dir\n");
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf);
- const ext_root = path_buf[0..n];
-
- var list: std.array_list.Managed(Found) = .init(testing.allocator);
- defer {
- for (list.items) |*f| f.deinit(testing.allocator);
- list.deinit();
- }
-
- const result = scanDir(testing.allocator, testing.io, ext_root, .project, .extension, &list);
- try testing.expectError(error.DuplicateExtensionInDirectory, result);
-}
-
-test "applyShadowing keeps the latest occurrence" {
- var list: std.array_list.Managed(Found) = .init(testing.allocator);
- defer {
- for (list.items) |*f| f.deinit(testing.allocator);
- list.deinit();
- }
-
- inline for (.{
- .{ "shared", "/u/shared.lua", Source.user },
- .{ "only_user", "/u/only_user.lua", Source.user },
- .{ "shared", "/p/shared.lua", Source.project },
- .{ "only_project", "/p/only_project.lua", Source.project },
- }) |row| {
- try list.append(.{
- .name = try testing.allocator.dupe(u8, row[0]),
- .script_path = try testing.allocator.dupe(u8, row[1]),
- .package_root = null,
- .source = row[2],
- .kind = .extension,
- });
- }
-
- try applyShadowing(testing.allocator, &list);
- try testing.expectEqual(@as(usize, 3), list.items.len);
-
- var shared_count: usize = 0;
- var shared_source: ?Source = null;
- for (list.items) |f| {
- if (std.mem.eql(u8, f.name, "shared")) {
- shared_count += 1;
- shared_source = f.source;
- }
- }
- try testing.expectEqual(@as(usize, 1), shared_count);
- try testing.expectEqual(@as(?Source, .project), shared_source);
+/// Resolve an absolute path for a subdirectory of the tmp dir.
+fn realDir(tmp: *testing.TmpDir, sub: []const u8, buf: []u8) ![]const u8 {
+ const n = try tmp.dir.realPathFile(testing.io, sub, buf);
+ return buf[0..n];
}
-test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "user_ext");
- try makeDir(tmp.dir, "project_ext");
-
- try writeFile(tmp.dir, "user_ext/greet.lua",
- \\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "greet", description = "user version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "USER" end,
- \\}
- );
- try writeFile(tmp.dir, "project_ext/greet.lua",
- \\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "greet", description = "project version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "PROJECT" end,
- \\}
- );
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const user_len = try tmp.dir.realPathFile(testing.io, "user_ext", &path_buf);
- const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
- defer testing.allocator.free(user_path);
-
- const proj_len = try tmp.dir.realPathFile(testing.io, "project_ext", &path_buf);
- const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
- defer testing.allocator.free(proj_path);
-
- var rt = try LuaRuntime.create(testing.allocator);
- defer rt.deinit();
-
- 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.
+fn invokeOne(rt: *LuaRuntime, name: []const u8, input: []const u8) !panto.ToolCallResult {
var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
+ const calls = [_]panto.ToolCall{.{ .tool_name = name, .input = input }};
var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("PROJECT", xokText(results[0]));
+ return results[0];
}
-test "loadFromDirs: tool-name collision between extensions errors" {
+test "loadFromDirs: activates a sugar tool and an entry extension" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "ext");
- try writeFile(tmp.dir, "ext/alpha.lua",
- \\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "clash", description = "a",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "a" end,
- \\}
- );
- try writeFile(tmp.dir, "ext/beta.lua",
- \\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "clash", description = "b",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "b" end,
- \\}
- );
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPathFile(testing.io, "ext", &path_buf);
- const ext_path = try testing.allocator.dupe(u8, path_buf[0..n]);
- defer testing.allocator.free(ext_path);
-
- var rt = try LuaRuntime.create(testing.allocator);
- defer rt.deinit();
-
- const result = loadFromDirs(testing.allocator, testing.io, rt, .{
- .project_extensions = ext_path,
- }, .{});
- try testing.expectError(error.DuplicateTool, result);
-}
-
-test "loadFromDirs: tools/ directory — single-file tool form" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "tools");
- try writeFile(tmp.dir, "tools/echo.lua",
+ try makeDir(tmp.dir, "d");
+ // Sugar tool form (return a table with handler).
+ try writeFile(tmp.dir, "d/echo.lua",
\\return {
- \\ name = "echo", description = "Echo back input.",
- \\ schema = { type = "object", properties = { msg = { type = "string" } } },
- \\ handler = function(input) return "echo: " .. input.msg end,
+ \\ name = "echo", description = "e",
+ \\ schema = { type = "object", properties = { m = { type = "string" } } },
+ \\ handler = function(input) return "echo: " .. input.m 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();
-
- 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();
- const calls = [_]panto.ToolCall{.{ .tool_name = "echo", .input = "{\"msg\":\"hi\"}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("echo: hi", xokText(results[0]));
-}
-
-test "loadFromDirs: tools/ directory — directory-style tool with sibling require" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "tools/shout");
- try writeFile(tmp.dir, "tools/shout/util.lua",
- \\local M = {}
- \\function M.shout(s) return s:upper() .. "!" end
- \\return M
- );
- try writeFile(tmp.dir, "tools/shout/init.lua",
- \\local util = require("util")
- \\return {
- \\ name = "shout", description = "uppercase + bang",
- \\ schema = { type = "object", properties = { text = { type = "string" } } },
- \\ handler = function(input) return util.shout(input.text) end,
- \\}
+ // Entry form with deferred activate().
+ try writeFile(tmp.dir, "d/greet.lua",
+ \\local panto = require("panto")
+ \\return { name = "greet", activate = function()
+ \\ panto.ext.register_tool {
+ \\ name = "greet", description = "g",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "hi" end,
+ \\ }
+ \\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 buf: [std.fs.max_path_bytes]u8 = undefined;
+ const d = try realDir(&tmp, "d", &buf);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .project_tools = tools_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = d, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectEqual(@as(usize, 2), n);
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "shout", .input = "{\"text\":\"hi\"}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("HI!", xokText(results[0]));
+ const r = try invokeOne(rt, "echo", "{\"m\":\"hi\"}");
+ defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r}));
+ try testing.expectEqualStrings("echo: hi", xokText(r));
}
-test "loadFromDirs: project tool shadows user tool of the same name" {
+test "loadFromDirs: higher precedence shadows same name" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "user_tools");
- try makeDir(tmp.dir, "project_tools");
-
- try writeFile(tmp.dir, "user_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "user version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "USER" end,
- \\}
+ try makeDir(tmp.dir, "u");
+ try makeDir(tmp.dir, "p");
+ try writeFile(tmp.dir, "u/greet.lua",
+ \\return { name = "greet", description = "u", schema = { type = "object" },
+ \\ handler = function(input) return "USER" end }
);
- try writeFile(tmp.dir, "project_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "project version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "PROJECT" end,
- \\}
+ try writeFile(tmp.dir, "p/greet.lua",
+ \\return { name = "greet", description = "p", schema = { type = "object" },
+ \\ handler = function(input) return "PROJECT" end }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf);
- const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
- defer testing.allocator.free(user_path);
-
- const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf);
- const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
- defer testing.allocator.free(proj_path);
+ var ubuf: [std.fs.max_path_bytes]u8 = undefined;
+ var pbuf: [std.fs.max_path_bytes]u8 = undefined;
+ const u = try realDir(&tmp, "u", &ubuf);
+ const p = try realDir(&tmp, "p", &pbuf);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- 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);
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = u, .source = .{ .layer = .user, .origin = .dir } },
+ .{ .path = p, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectEqual(@as(usize, 1), n);
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("PROJECT", xokText(results[0]));
+ const r = try invokeOne(rt, "greet", "{}");
+ defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r}));
+ try testing.expectEqualStrings("PROJECT", xokText(r));
}
-test "loadFromDirs: project tool shadows user shadows base" {
+test "loadFromDirs: within a layer, dir shadows paths shadows rocks" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "base_tools");
- try makeDir(tmp.dir, "user_tools");
- try makeDir(tmp.dir, "project_tools");
-
- try writeFile(tmp.dir, "base_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "base version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "BASE" end,
- \\}
- );
- try writeFile(tmp.dir, "user_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "user version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "USER" end,
- \\}
- );
- try writeFile(tmp.dir, "project_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "project version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "PROJECT" end,
- \\}
- );
+ try makeDir(tmp.dir, "rock");
+ try makeDir(tmp.dir, "path");
+ try makeDir(tmp.dir, "dir");
+ inline for (.{ .{ "rock", "ROCK" }, .{ "path", "PATH" }, .{ "dir", "DIR" } }) |row| {
+ try writeFile(tmp.dir, row[0] ++ "/w.lua", "return { name = \"w\", description = \"d\", schema = { type = \"object\" }," ++
+ " handler = function(input) return \"" ++ row[1] ++ "\" end }");
+ }
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- 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);
- const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
- defer testing.allocator.free(user_path);
- const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf);
- const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
- defer testing.allocator.free(proj_path);
+ var b1: [std.fs.max_path_bytes]u8 = undefined;
+ var b2: [std.fs.max_path_bytes]u8 = undefined;
+ var b3: [std.fs.max_path_bytes]u8 = undefined;
+ const rock = try realDir(&tmp, "rock", &b1);
+ const path = try realDir(&tmp, "path", &b2);
+ const dir = try realDir(&tmp, "dir", &b3);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .base_tools = sys_path,
- .user_tools = user_path,
- .project_tools = proj_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
+ // All at the same layer; only within-layer origin differs.
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = rock, .source = .{ .layer = .user, .origin = .rocks } },
+ .{ .path = path, .source = .{ .layer = .user, .origin = .paths } },
+ .{ .path = dir, .source = .{ .layer = .user, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectEqual(@as(usize, 1), n);
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("PROJECT", xokText(results[0]));
+ const r = try invokeOne(rt, "w", "{}");
+ defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r}));
+ try testing.expectEqualStrings("DIR", xokText(r));
}
-test "loadFromDirs: user tool shadows base tool when no project entry" {
+test "loadFromDirs: same-precedence duplicate name is an error" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "base_tools");
- try makeDir(tmp.dir, "user_tools");
-
- try writeFile(tmp.dir, "base_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "base",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "BASE" end,
- \\}
+ try makeDir(tmp.dir, "a");
+ try makeDir(tmp.dir, "b");
+ try writeFile(tmp.dir, "a/x.lua",
+ \\return { name = "dup", description = "a", schema = { type = "object" },
+ \\ handler = function(input) return "a" end }
);
- try writeFile(tmp.dir, "user_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "user",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "USER" end,
- \\}
+ try writeFile(tmp.dir, "b/y.lua",
+ \\return { name = "dup", description = "b", schema = { type = "object" },
+ \\ handler = function(input) return "b" end }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- 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);
- const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
- defer testing.allocator.free(user_path);
+ var ab: [std.fs.max_path_bytes]u8 = undefined;
+ var bb: [std.fs.max_path_bytes]u8 = undefined;
+ const a = try realDir(&tmp, "a", &ab);
+ const b = try realDir(&tmp, "b", &bb);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .base_tools = sys_path,
- .user_tools = user_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
-
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("USER", xokText(results[0]));
+ // Same layer AND same origin → same rank → ambiguous.
+ const result = loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = a, .source = .{ .layer = .project, .origin = .dir } },
+ .{ .path = b, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectError(error.DuplicateExtensionName, result);
}
-test "loadFromDirs: extension and tool share a *file* name independently" {
- // The shadow-key is (kind, name) so an extension named `foo` and a
- // tool named `foo` coexist — as long as their *registered* tool names
- // don't collide.
+test "loadFromDirs: tool-name collision across entries errors" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "ext");
- try makeDir(tmp.dir, "tools");
-
- try writeFile(tmp.dir, "ext/foo.lua",
+ try makeDir(tmp.dir, "d");
+ // Two distinct entry names, but both register the same tool name.
+ try writeFile(tmp.dir, "d/a.lua",
\\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "ext_foo", description = "e",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "ext" end,
- \\}
+ \\return { name = "a", activate = function()
+ \\ panto.ext.register_tool { name = "clash", description = "a",
+ \\ schema = { type = "object" }, handler = function() return "a" end }
+ \\end }
);
- try writeFile(tmp.dir, "tools/foo.lua",
- \\return {
- \\ name = "tool_foo", description = "t",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "tool" end,
- \\}
+ try writeFile(tmp.dir, "d/b.lua",
+ \\local panto = require("panto")
+ \\return { name = "b", activate = function()
+ \\ panto.ext.register_tool { name = "clash", description = "b",
+ \\ schema = { type = "object" }, handler = function() return "b" end }
+ \\end }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const e_len = try tmp.dir.realPathFile(testing.io, "ext", &path_buf);
- const ext_path = try testing.allocator.dupe(u8, path_buf[0..e_len]);
- defer testing.allocator.free(ext_path);
-
- const t_len = try tmp.dir.realPathFile(testing.io, "tools", &path_buf);
- const tools_path = try testing.allocator.dupe(u8, path_buf[0..t_len]);
- defer testing.allocator.free(tools_path);
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const d = try realDir(&tmp, "d", &buf);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- 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);
+ const result = loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = d, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectError(error.DuplicateTool, result);
}
-test "loadFromDirs: tools policy removes a denied registered tool name" {
+test "loadFromDirs: policy denies a name — its activate() never runs" {
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 makeDir(tmp.dir, "d");
+ // activate() would crash if it ran; the deny must prevent activation.
+ // eval (the top-level return) is always safe.
+ try writeFile(tmp.dir, "d/danger.lua",
+ \\return { name = "danger", activate = function()
+ \\ error("activate must not run")
+ \\end }
);
- try writeFile(tmp.dir, "tools/sheller.lua",
- \\return {
- \\ name = "std.shell", description = "s",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "SHELL" end,
- \\}
+ try writeFile(tmp.dir, "d/ok.lua",
+ \\return { name = "ok", description = "o", schema = { type = "object" },
+ \\ handler = function(input) return "ok" 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 buf: [std.fs.max_path_bytes]u8 = undefined;
+ const d = try realDir(&tmp, "d", &buf);
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 rules = try testing.allocator.alloc(config_file.Rule, 1);
+ rules[0] = .{ .pattern = try testing.allocator.dupe(u8, "danger"), .verdict = .deny, .layer = 0, .spec = @import("glob.zig").specificity("danger") };
+ const policy: config_file.Policy = .{ .rules = rules };
+ defer policy.deinit(testing.allocator);
- 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 xfreeResults(&results);
- try testing.expectEqualStrings("READ", xokText(results[0]));
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = d, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, &policy);
+ // Only "ok" activates; "danger" is denied (its activate never runs).
+ try testing.expectEqual(@as(usize, 1), n);
}
-test "loadFromDirs: extensions policy denies a whole entry before it loads" {
+test "loadFromDirs: a source returning a list registers multiple entries" {
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")
+ try makeDir(tmp.dir, "d");
+ try writeFile(tmp.dir, "d/multi.lua",
+ \\local panto = require("panto")
+ \\local function tool(nm)
+ \\ return { name = nm, activate = function()
+ \\ panto.ext.register_tool { name = nm, description = nm,
+ \\ schema = { type = "object" }, handler = function() return nm end }
+ \\ end }
+ \\end
+ \\return { tool("agent.skills"), tool("agent.rules") }
);
- 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 buf: [std.fs.max_path_bytes]u8 = undefined;
+ const d = try realDir(&tmp, "d", &buf);
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);
+ // Deny one of the two namespaces; the other still activates.
+ var rules = try testing.allocator.alloc(config_file.Rule, 1);
+ rules[0] = .{ .pattern = try testing.allocator.dupe(u8, "agent.rules"), .verdict = .deny, .layer = 0, .spec = @import("glob.zig").specificity("agent.rules") };
+ const policy: config_file.Policy = .{ .rules = rules };
+ defer policy.deinit(testing.allocator);
+
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = d, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, &policy);
+ try testing.expectEqual(@as(usize, 1), n);
- 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);
+ const r = try invokeOne(rt, "agent.skills", "{}");
+ defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r}));
+ try testing.expectEqualStrings("agent.skills", xokText(r));
}
+
diff --git a/src/glob.zig b/src/glob.zig
index 83d9fcb..7072e95 100644
--- a/src/glob.zig
+++ b/src/glob.zig
@@ -54,11 +54,85 @@ fn matchSegments(pat_it: *SegmentIter, name_it: *SegmentIter) bool {
}
// ---------------------------------------------------------------------------
+// Specificity
+// ---------------------------------------------------------------------------
+
+/// How specific a pattern is, used to rank rules that match the same name in
+/// an allow/deny list. `order` gives an *ascending* order: less specific
+/// sorts before more specific, so a "last matching rule wins" scan picks the
+/// most specific match. Only meaningful when comparing patterns that both
+/// match a given name; the metric is a consistent proxy (narrower match set).
+pub const Specificity = struct {
+ /// Count of literal (non-wildcard) segments — the dominant signal.
+ literals: usize = 0,
+ /// Total segment count (a longer pattern constrains more positions).
+ segments: usize = 0,
+ /// Count of `**` segments (the loosest wildcard).
+ double_stars: usize = 0,
+
+ /// Ascending order: `.lt` means `a` is *less* specific than `b`. Ranking:
+ /// 1. more literal segments (a.b.* > a.*, a.b.** > a.**)
+ /// 2. more total segments (a.*.* > a.*)
+ /// 3. fewer `**` segments (a.* > a.**)
+ pub fn order(a: Specificity, b: Specificity) std.math.Order {
+ if (a.literals != b.literals) return std.math.order(a.literals, b.literals);
+ if (a.segments != b.segments) return std.math.order(a.segments, b.segments);
+ // Fewer `**` → more specific (invert: `*` beats `**`).
+ return std.math.order(b.double_stars, a.double_stars);
+ }
+};
+
+/// Compute the specificity of a dot-delimited glob pattern.
+pub fn specificity(pattern: []const u8) Specificity {
+ var s: Specificity = .{};
+ var it = std.mem.splitScalar(u8, pattern, '.');
+ while (it.next()) |seg| {
+ s.segments += 1;
+ if (std.mem.eql(u8, seg, "**")) {
+ s.double_stars += 1;
+ } else if (!std.mem.eql(u8, seg, "*")) {
+ s.literals += 1;
+ }
+ }
+ return s;
+}
+
+// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
+test "specificity: more literals wins" {
+ // agent.rules (2 literals) is more specific than agent.* (1 literal).
+ try testing.expectEqual(std.math.Order.gt, specificity("agent.rules").order(specificity("agent.*")));
+ try testing.expectEqual(std.math.Order.lt, specificity("agent.*").order(specificity("agent.rules")));
+}
+
+test "specificity: single star beats double star" {
+ // Same literal count and length; `*` is more specific than `**`.
+ try testing.expectEqual(std.math.Order.gt, specificity("agent.*").order(specificity("agent.**")));
+}
+
+test "specificity: a longer glob trumps a shorter one" {
+ // Same literal count; more segments constrains more → more specific.
+ try testing.expectEqual(std.math.Order.gt, specificity("a.*.*").order(specificity("a.*")));
+ try testing.expectEqual(std.math.Order.gt, specificity("a.**.**").order(specificity("a.**")));
+ // Literal count still dominates length.
+ try testing.expectEqual(std.math.Order.gt, specificity("a.b.*").order(specificity("a.*")));
+ try testing.expectEqual(std.math.Order.gt, specificity("a.b.**").order(specificity("a.**")));
+}
+
+test "specificity: literal-heavier pattern beats a broad ** even if longer" {
+ // agent.rules.detail (3 literals) more specific than agent.** (1 literal).
+ try testing.expectEqual(std.math.Order.gt, specificity("agent.rules.detail").order(specificity("agent.**")));
+}
+
+test "specificity: bare ** is the least specific" {
+ try testing.expectEqual(std.math.Order.lt, specificity("**").order(specificity("agent.rules")));
+ try testing.expectEqual(std.math.Order.lt, specificity("**").order(specificity("*")));
+}
+
test "literal match" {
try testing.expect(match("std.read", "std.read"));
try testing.expect(!match("std.read", "std.write"));
diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig
index 56ce0db..6b3a2f9 100644
--- a/src/lua_runtime.zig
+++ b/src/lua_runtime.zig
@@ -273,117 +273,214 @@ pub const LuaRuntime = struct {
self.allocator.destroy(self);
}
- /// Load and execute one Lua extension script in this runtime.
+ /// A declared but not-yet-activated extension entry: its `name` (the
+ /// identity that the availability policy gates on) and a Lua registry
+ /// ref to either its `activate` function or — for the sugar tool form —
+ /// the tool table itself. Produced by `evalEntries`; consumed by exactly
+ /// one of `activateEntry` / `dropEntry`, both of which unref the ref and
+ /// free `name`.
+ pub const Entry = struct {
+ name: []u8,
+ ref: c_int,
+ is_tool_table: bool,
+ };
+
+ /// Evaluate one Lua source file and append the entries it declares to
+ /// `out`, WITHOUT activating them — no tools/commands are registered yet.
///
- /// `package_root`, if provided, is prepended to `package.path` so
- /// `require` finds sibling modules.
+ /// The chunk must be side-effect-free (this runs over *every* candidate,
+ /// including ones that will be shadowed or denied) and return either:
+ /// - an entry table `{ name = <string>, activate = <function> }`,
+ /// - the sugar tool form `{ name, description, schema, handler }`
+ /// (no `activate`; activation registers it as a tool), or
+ /// - a list `{ <entry|tool>, <entry|tool>, ... }` of the above.
///
- /// All `panto.ext.register_tool` calls in the script run during this
- /// call. The runtime then harvests the registrations table,
- /// transfers handler functions into the Lua registry (one `luaL_ref`
- /// per tool), and records each tool's metadata in `self.decls`.
- pub fn loadExtension(
+ /// `package_root`, if provided, is prepended to `package.path`.
+ pub fn evalEntries(
self: *LuaRuntime,
script_path: []const u8,
package_root: ?[]const u8,
+ out: *std.array_list.Managed(Entry),
) !void {
+ const L = self.L;
const path_z = try self.allocator.dupeZ(u8, script_path);
defer self.allocator.free(path_z);
- // Reset the registrations table to empty so we only harvest the
- // calls made by *this* script (not accumulated from prior ones).
- // The bridge re-installs the registrations table when called;
- // we want to call only that subset. Instead of re-installing
- // everything (which would also reset the panto global, fine),
- // create a fresh registrations table directly via the bridge.
- lua_bridge.resetRegistrations(self.L);
-
if (package_root) |root| {
const root_z = try self.allocator.dupeZ(u8, root);
defer self.allocator.free(root_z);
- try prependPackagePath(self.L, root_z);
+ try prependPackagePath(L, root_z);
}
- lua_bridge.loadFile(self.L, path_z) catch |err| {
- logTopAsError(self.L, "lua: failed to load extension");
- return err;
- };
+ if (c.luaL_loadfilex(L, path_z.ptr, null) != 0) {
+ logTopAsError(L, "lua: failed to load extension");
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return error.LuaLoadFailed;
+ }
+ if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) {
+ logTopAsError(L, "lua: failed to evaluate extension");
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return error.LuaRunFailed;
+ }
+ // Stack: ..., returned_value
+ defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop returned value
+ try self.collectEntriesFromTop(script_path, out);
+ }
- // Harvest the registrations table into our state.
- try self.harvestAndStoreHandlers();
+ /// Evaluate an installed rock (or any `require`-able module) and append
+ /// the entries it declares to `out`. Same contract as `evalEntries`: the
+ /// module's chunk must be side-effect-free and return an entry, sugar
+ /// tool, or list. Used to load `extensions.rocks`.
+ pub fn evalEntriesFromModule(
+ self: *LuaRuntime,
+ module_name: []const u8,
+ out: *std.array_list.Managed(Entry),
+ ) !void {
+ const L = self.L;
+ _ = c.lua_getglobal(L, "require");
+ _ = c.lua_pushlstring(L, module_name.ptr, module_name.len);
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
+ logTopAsError(L, "lua: failed to require rock");
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return error.LuaRunFailed;
+ }
+ // Stack: ..., returned_value
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+ try self.collectEntriesFromTop(module_name, out);
}
- /// Load a single-tool Lua script and register the table it returns
- /// as if `panto.ext.register_tool` had been called on that table.
- ///
- /// The script's top-level chunk must return a table with the same
- /// shape that `panto.ext.register_tool` accepts:
- /// `{ name, description, schema, handler }`. This is the ergonomic
- /// form supported under a `tools/` directory.
- pub fn loadTool(
+ /// Classify the value at the top of the stack (does not pop it) as an
+ /// entry, a sugar tool, or a list of those, appending each to `out`.
+ fn collectEntriesFromTop(
self: *LuaRuntime,
- script_path: []const u8,
- package_root: ?[]const u8,
+ what: []const u8,
+ out: *std.array_list.Managed(Entry),
) !void {
- const path_z = try self.allocator.dupeZ(u8, script_path);
- defer self.allocator.free(path_z);
+ const L = self.L;
+ if (c.lua_type(L, -1) != lua_bridge.T_TABLE) {
+ std.log.err("lua: extension '{s}' must return an entry table or list", .{what});
+ return error.BadRegistration;
+ }
- lua_bridge.resetRegistrations(self.L);
+ // A single entry/tool has a string `name`; otherwise it's a list.
+ _ = c.lua_getfield(L, -1, "name");
+ const has_name = c.lua_type(L, -1) == lua_bridge.T_STRING;
+ c.lua_settop(L, c.lua_gettop(L) - 1);
- if (package_root) |root| {
- const root_z = try self.allocator.dupeZ(u8, root);
- defer self.allocator.free(root_z);
- try prependPackagePath(self.L, root_z);
+ if (has_name) {
+ try self.readOneEntry(what, out);
+ return;
}
- // Run the file expecting exactly one returned value (the tool
- // table). Use luaL_loadfilex + lua_pcallk directly so we can
- // ask for a return value (the bridge's loadFile discards them).
- //
- // Push `panto.ext.register_tool` *first*, then load+run the chunk
- // so its return value naturally lands above it; calling pcall then
- // consumes both in the right order. We reach `register_tool`
- // through the `ext` table directly (it exists from `install`,
- // independent of whether the native module table has been wired).
- const L = self.L;
- lua_bridge.pushExtTable(L);
- _ = c.lua_getfield(L, -1, "register_tool");
- // Stack: ..., ext, register_tool. Collapse to just register_tool.
- c.lua_copy(L, -1, -2); // overwrite `ext` with `register_tool`
- c.lua_settop(L, c.lua_gettop(L) - 1); // pop duplicate
- // Stack: ..., register_tool
-
- if (c.luaL_loadfilex(L, path_z.ptr, null) != 0) {
- logTopAsError(L, "lua: failed to load tool");
- c.lua_settop(L, c.lua_gettop(L) - 2); // pop err + register_tool
- return error.LuaLoadFailed;
+ const n: usize = @intCast(c.lua_rawlen(L, -1));
+ if (n == 0) {
+ std.log.err("lua: extension '{s}' returned a table with no `name` and no entries", .{what});
+ return error.BadRegistration;
}
- if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) {
- logTopAsError(L, "lua: failed to run tool");
- c.lua_settop(L, c.lua_gettop(L) - 2); // pop err + register_tool
- return error.LuaRunFailed;
+ var i: usize = 1;
+ while (i <= n) : (i += 1) {
+ _ = c.lua_rawgeti(L, -1, @intCast(i)); // push element
+ if (c.lua_type(L, -1) != lua_bridge.T_TABLE) {
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ std.log.err("lua: extension '{s}' entry {d} is not a table", .{ what, i });
+ return error.BadRegistration;
+ }
+ try self.readOneEntry(what, out);
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop element
}
- // Stack: ..., register_tool, returned_value
- if (c.lua_type(L, -1) != lua_bridge.T_TABLE) {
- c.lua_settop(L, c.lua_gettop(L) - 2); // pop both
- std.log.err(
- "lua: tool script '{s}' must return a table",
- .{script_path},
- );
+ }
+
+ /// Read one entry table sitting at the top of the stack (does not pop
+ /// it), ref its activator, and append an `Entry` to `out`.
+ fn readOneEntry(
+ self: *LuaRuntime,
+ script_path: []const u8,
+ out: *std.array_list.Managed(Entry),
+ ) !void {
+ const L = self.L;
+ const t = c.lua_gettop(L); // absolute index of the entry table
+
+ _ = c.lua_getfield(L, t, "name");
+ if (c.lua_type(L, -1) != lua_bridge.T_STRING) {
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ std.log.err("lua: extension '{s}' entry has no string `name`", .{script_path});
return error.BadRegistration;
}
+ var nlen: usize = 0;
+ const nptr = c.lua_tolstring(L, -1, &nlen);
+ const name = try self.allocator.dupe(u8, nptr[0..nlen]);
+ errdefer self.allocator.free(name);
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop name
- // Invoke register_tool(returned_table). Same validation, schema
- // serialization, and registrations-table append logic as an
- // extension's `panto.ext.register_tool` call.
- if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) {
- logTopAsError(L, "lua: register_tool failed for tool script");
- return error.LuaRunFailed;
+ var ref: c_int = undefined;
+ var is_tool_table = false;
+
+ _ = c.lua_getfield(L, t, "activate");
+ if (c.lua_type(L, -1) == lua_bridge.T_FUNCTION) {
+ ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); // refs + pops the fn
+ } else {
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop non-function activate
+ _ = c.lua_getfield(L, t, "handler");
+ const is_handler = c.lua_type(L, -1) == lua_bridge.T_FUNCTION;
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop handler probe
+ if (!is_handler) {
+ std.log.err("lua: extension '{s}' entry '{s}' has neither `activate` nor `handler`", .{ script_path, name });
+ return error.BadRegistration;
+ }
+ // Sugar tool form: ref the table itself; activation registers it.
+ c.lua_pushvalue(L, t); // copy of the tool table
+ ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); // refs + pops copy
+ is_tool_table = true;
}
+ try out.append(.{ .name = name, .ref = ref, .is_tool_table = is_tool_table });
+ }
+
+ /// Activate an entry: run its `activate` function (or, for the sugar
+ /// form, `register_tool(<tool table>)`), then harvest the tools,
+ /// commands, and event handlers it registered. Consumes the entry
+ /// (unrefs its ref, frees its name).
+ pub fn activateEntry(self: *LuaRuntime, entry: Entry) !void {
+ const L = self.L;
+ // Isolate this entry's registrations so harvest picks up only its own.
+ lua_bridge.resetRegistrations(L);
+
+ if (entry.is_tool_table) {
+ lua_bridge.pushExtTable(L);
+ _ = c.lua_getfield(L, -1, "register_tool");
+ c.lua_copy(L, -1, -2); // overwrite `ext` with `register_tool`
+ c.lua_settop(L, c.lua_gettop(L) - 1); // Stack: register_tool
+ _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, entry.ref); // push tool table
+ if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) {
+ logTopAsError(L, "lua: register_tool failed for tool");
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ self.finishEntry(entry);
+ return error.LuaRunFailed;
+ }
+ } else {
+ _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, entry.ref); // push activate fn
+ if (c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) {
+ logTopAsError(L, "lua: extension activate() failed");
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ self.finishEntry(entry);
+ return error.LuaRunFailed;
+ }
+ }
+
+ self.finishEntry(entry);
try self.harvestAndStoreHandlers();
}
+ /// Discard an entry without activating it (shadowed or denied).
+ pub fn dropEntry(self: *LuaRuntime, entry: Entry) void {
+ self.finishEntry(entry);
+ }
+
+ fn finishEntry(self: *LuaRuntime, entry: Entry) void {
+ c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.ref);
+ self.allocator.free(entry.name);
+ }
+
/// Walk the registrations table that the script just populated.
/// For each entry:
/// - Copy `name`, `description`, `schema_json` into owned bytes.
@@ -1285,17 +1382,68 @@ fn freeResults(results: []panto.ToolCallResult) void {
fn writeTempScript(dir: Io.Dir, name: []const u8, source: []const u8) ![]const u8 {
// `panto` is not a global; extensions reach it via `require('panto')`.
- // Prepend the require so these tests exercise the real preload path
- // without each script repeating the boilerplate.
+ // The register-based `source` body is wrapped in an entry whose
+ // `activate()` runs it, matching the new deferred-registration model.
var src_buf: [16384]u8 = undefined;
- const data = try std.fmt.bufPrint(&src_buf, "local panto = require(\"panto\")\n{s}", .{source});
+ const data = try std.fmt.bufPrint(
+ &src_buf,
+ "local panto = require(\"panto\")\nreturn {{ name = \"_t\", activate = function()\n{s}\nend }}\n",
+ .{source},
+ );
try dir.writeFile(testing.io, .{ .sub_path = name, .data = data });
var buf: [std.fs.max_path_bytes]u8 = undefined;
const n = try dir.realPathFile(testing.io, name, &buf);
return testing.allocator.dupe(u8, buf[0..n]);
}
-test "loadExtension records tool decls" {
+/// Test helper: eval one script and activate every entry it declares
+/// (no policy, no shadowing) — the single-file analogue of the loader.
+fn loadScript(rt: *LuaRuntime, path: []const u8, package_root: ?[]const u8) !void {
+ var entries: std.array_list.Managed(LuaRuntime.Entry) = .init(rt.allocator);
+ defer entries.deinit();
+ rt.evalEntries(path, package_root, &entries) catch |e| {
+ for (entries.items) |it| rt.dropEntry(it);
+ return e;
+ };
+ for (entries.items, 0..) |it, idx| {
+ rt.activateEntry(it) catch |e| {
+ var j = idx + 1;
+ while (j < entries.items.len) : (j += 1) rt.dropEntry(entries.items[j]);
+ return e;
+ };
+ }
+}
+
+test "evalEntriesFromModule loads a rock's entries via require" {
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+
+ // Stand in for an installed rock: preload a module returning entries.
+ const preload =
+ \\package.preload["fakerock"] = function()
+ \\ local panto = require("panto")
+ \\ return {
+ \\ { name = "rock.a", activate = function()
+ \\ panto.ext.register_tool { name = "rock.a", description = "a",
+ \\ schema = { type = "object" }, handler = function() return "A" end }
+ \\ end },
+ \\ { name = "rock.b", description = "b", schema = { type = "object" },
+ \\ handler = function() return "B" end },
+ \\ }
+ \\end
+ ;
+ if (c.luaL_loadstring(rt.L, preload) != 0) return error.TestSetupFailed;
+ if (c.lua_pcallk(rt.L, 0, 0, 0, 0, null) != 0) return error.TestSetupFailed;
+
+ var entries: std.array_list.Managed(LuaRuntime.Entry) = .init(testing.allocator);
+ defer entries.deinit();
+ try rt.evalEntriesFromModule("fakerock", &entries);
+ try testing.expectEqual(@as(usize, 2), entries.items.len);
+ for (entries.items) |e| try rt.activateEntry(e);
+ try testing.expectEqual(@as(usize, 2), rt.toolCount());
+}
+
+test "loadScript records tool decls" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
@@ -1312,12 +1460,12 @@ test "loadExtension records tool decls" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
try testing.expectEqual(@as(usize, 1), rt.toolCount());
try testing.expectEqualStrings("greet", rt.decls.items[0].name);
}
-test "loadExtension records command decls" {
+test "loadScript records command decls" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
@@ -1334,7 +1482,7 @@ test "loadExtension records command decls" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
const cmds = rt.commandList();
try testing.expectEqual(@as(usize, 1), cmds.len);
try testing.expectEqualStrings("shout", cmds[0].name);
@@ -1369,7 +1517,7 @@ test "runCommand surfaces a Lua error" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
const cmds = rt.commandList();
var err_buf: [4096]u8 = undefined;
@@ -1394,7 +1542,7 @@ test "duplicate command name within runtime is rejected" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try testing.expectError(error.DuplicateCommand, rt.loadExtension(path, null));
+ try testing.expectError(error.DuplicateCommand, loadScript(rt, path, null));
}
test "invokeBatch runs each call through a coroutine and returns the result" {
@@ -1418,7 +1566,7 @@ test "invokeBatch runs each call through a coroutine and returns the result" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
var src = rt.toolSource();
@@ -1462,7 +1610,7 @@ test "module-global state survives across calls in the same runtime" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
var src = rt.toolSource();
const calls = [_]panto.ToolCall{
@@ -1516,7 +1664,7 @@ test "handler crash: per-call error surfaces, sibling calls succeed" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
var src = rt.toolSource();
const calls = [_]panto.ToolCall{
@@ -1566,7 +1714,7 @@ test "directory-style extension can require sibling modules" {
.data =
\\local panto = require("panto")
\\local util = require("util")
- \\panto.ext.register_tool {
+ \\return {
\\ name = "shout", description = "uppercase + bang",
\\ schema = { type = "object", properties = { text = { type = "string" } } },
\\ handler = function(input) return util.shout(input.text) end,
@@ -1584,7 +1732,7 @@ test "directory-style extension can require sibling modules" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(init_path, ext_dir);
+ try loadScript(rt, init_path, ext_dir);
var src = rt.toolSource();
@@ -1611,7 +1759,7 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
var src = rt.toolSource();
const calls = [_]panto.ToolCall{.{ .tool_name = "sleeper", .input = "{}" }};
@@ -1696,7 +1844,7 @@ test "scheduler: yielding handler is resumed by libuv" {
defer luarocks_rt.deinit();
try rt.installScheduler();
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
var src = rt.toolSource();
const calls = [_]panto.ToolCall{
@@ -1742,7 +1890,7 @@ test "scheduler: handler that yields without arming libuv work is surfaced as an
;
const path = try writeTempScript(tmp.dir, "abandon.lua", source);
defer testing.allocator.free(path);
- try rt.loadExtension(path, null);
+ try loadScript(rt, path, null);
var src = rt.toolSource();
const calls = [_]panto.ToolCall{
@@ -1837,7 +1985,7 @@ test "scheduler: two real std.shell calls in one batch do not deadlock" {
defer testing.allocator.free(tools_dir);
const shell_path = try std.fs.path.join(testing.allocator, &.{ tools_dir, "shell.lua" });
defer testing.allocator.free(shell_path);
- try rt.loadTool(shell_path, tools_dir);
+ try loadScript(rt, shell_path, tools_dir);
var src = rt.toolSource();
const calls = [_]panto.ToolCall{
@@ -1871,7 +2019,7 @@ test "scheduler: shell timeout is measured from dispatch, not a stale loop clock
defer testing.allocator.free(tools_dir);
const shell_path = try std.fs.path.join(testing.allocator, &.{ tools_dir, "shell.lua" });
defer testing.allocator.free(shell_path);
- try rt.loadTool(shell_path, tools_dir);
+ try loadScript(rt, shell_path, tools_dir);
var src = rt.toolSource();
@@ -1912,7 +2060,7 @@ test "scheduler: three real std.shell calls with explicit timeout all succeed" {
defer testing.allocator.free(tools_dir);
const shell_path = try std.fs.path.join(testing.allocator, &.{ tools_dir, "shell.lua" });
defer testing.allocator.free(shell_path);
- try rt.loadTool(shell_path, tools_dir);
+ try loadScript(rt, shell_path, tools_dir);
var src = rt.toolSource();
const calls = [_]panto.ToolCall{
@@ -1950,7 +2098,7 @@ test "scheduler: two real std.read calls in one batch do not deadlock" {
defer testing.allocator.free(tools_dir);
const read_path = try std.fs.path.join(testing.allocator, &.{ tools_dir, "read.lua" });
defer testing.allocator.free(read_path);
- try rt.loadTool(read_path, tools_dir);
+ try loadScript(rt, read_path, tools_dir);
const in0 = try std.fmt.allocPrint(testing.allocator, "{{\"path\":\"{s}/shell.lua\"}}", .{tools_dir});
defer testing.allocator.free(in0);
@@ -1974,7 +2122,7 @@ test "scheduler: two real std.read calls in one batch do not deadlock" {
try testing.expect(okText(results[1]).len > 0);
}
-test "loadExtension: duplicate tool name from a second extension errors" {
+test "loadScript: duplicate tool name from a second extension errors" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
@@ -1999,7 +2147,7 @@ test "loadExtension: duplicate tool name from a second extension errors" {
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- try rt.loadExtension(pa, null);
+ try loadScript(rt, pa, null);
- try testing.expectError(error.DuplicateTool, rt.loadExtension(pb, null));
+ try testing.expectError(error.DuplicateTool, loadScript(rt, pb, null));
}
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
index 1505b8f..96b2e37 100644
--- a/src/luarocks_runtime.zig
+++ b/src/luarocks_runtime.zig
@@ -916,9 +916,20 @@ fn batteryInstalled(
layout: panto_home.Layout,
battery: manifest.Battery,
) !bool {
+ return rockMetaExists(allocator, io, layout, battery.name, battery.version);
+}
+
+/// Whether `<tree>/lib/luarocks/rocks-<short>/<name>/<version>/` exists.
+fn rockMetaExists(
+ allocator: Allocator,
+ io: Io,
+ layout: panto_home.Layout,
+ name: []const u8,
+ version: []const u8,
+) !bool {
const subpath = try std.fs.path.join(
allocator,
- &.{ layout.rocks_metadata_dir, battery.name, battery.version },
+ &.{ layout.rocks_metadata_dir, name, version },
);
defer allocator.free(subpath);
@@ -955,10 +966,92 @@ fn installBattery(
L: *c.lua_State,
battery: manifest.Battery,
) !void {
- // We wrap the embedded driver in a function that overrides
- // `os.exit` for the duration of the call. luarocks's `die()`
- // ultimately calls `os.exit(1)`, which would terminate panto
- // entirely; we want to catch the failure as a regular error.
+ _ = allocator; // no scratch needed
+ try runLuarocksDriver(L, &.{ "install", battery.name, battery.version }, battery.name);
+}
+
+/// Install a user rock from a luarocks dependency spec (e.g.
+/// `"panto-agent 1.4.2-1"`), running `luarocks install <spec-tokens>` in
+/// process against `rt`'s `lua_State`. The spec is passed through verbatim,
+/// split on whitespace into the positional `install` arguments luarocks
+/// expects (`<name> [<version>]`). Idempotent: luarocks skips a rock that is
+/// already installed at the requested version.
+pub fn installRock(rt: *LuarocksRuntime, allocator: Allocator, spec: []const u8) !void {
+ var args: std.array_list.Managed([]const u8) = .init(allocator);
+ defer args.deinit();
+ try args.append("install");
+ var it = std.mem.tokenizeAny(u8, spec, " \t");
+ while (it.next()) |tok| try args.append(tok);
+ if (args.items.len < 2) return error.LuarocksInstallFailed; // no rock name
+ try runLuarocksDriver(rt.L, args.items, spec);
+}
+
+/// Like `installRock`, but avoids a per-startup network hit: it skips the
+/// install when the local tree already has something matching the spec.
+/// - exact pin (`name version-revision`, no operators): skip if that exact
+/// version is installed.
+/// - bare name or range/operators: skip if the rock name has *any* version
+/// installed. Upgrading within a range is deferred to `panto update`.
+/// A spec whose rock is absent (new pin / new rock) always installs once.
+/// Returns true if an install actually ran.
+pub fn installRockIfMissing(
+ rt: *LuarocksRuntime,
+ allocator: Allocator,
+ io: Io,
+ spec: []const u8,
+) !bool {
+ var it = std.mem.tokenizeAny(u8, spec, " \t");
+ const name = it.next() orelse return error.LuarocksInstallFailed;
+ const version = it.next();
+ const rest = it.next();
+
+ const is_exact_pin = version != null and rest == null and
+ std.mem.indexOfAny(u8, version.?, "<>=~") == null;
+
+ const present = if (is_exact_pin)
+ try rockMetaExists(allocator, io, rt.layout, name, version.?)
+ else
+ try rockAnyVersionInstalled(allocator, io, rt.layout, name);
+
+ if (present) return false;
+ try installRock(rt, allocator, spec);
+ return true;
+}
+
+/// Whether `<tree>/lib/luarocks/rocks-<short>/<name>/` has any installed
+/// version subdirectory.
+fn rockAnyVersionInstalled(
+ allocator: Allocator,
+ io: Io,
+ layout: panto_home.Layout,
+ name: []const u8,
+) !bool {
+ const dirpath = try std.fs.path.join(allocator, &.{ layout.rocks_metadata_dir, name });
+ defer allocator.free(dirpath);
+
+ var dir = Io.Dir.cwd().openDir(io, dirpath, .{ .iterate = true }) catch |err| switch (err) {
+ error.FileNotFound, error.NotDir => return false,
+ else => return err,
+ };
+ defer dir.close(io);
+
+ var iter = dir.iterate();
+ while (try iter.next(io)) |entry| {
+ if (entry.name.len == 0 or entry.name[0] == '.') continue;
+ if (entry.kind == .directory) return true;
+ }
+ return false;
+}
+
+/// Run the embedded luarocks CLI driver in process with `cli_args`
+/// (e.g. `{"install", "<name>", "<version>"}`). `label` is used only for
+/// error messages. Overrides `os.exit` so a luarocks `die()` surfaces as a
+/// Zig error instead of terminating panto.
+fn runLuarocksDriver(
+ L: *c.lua_State,
+ cli_args: []const []const u8,
+ label: []const u8,
+) !void {
const driver = embedded_luarocks.luarocks_main;
const wrapper =
@@ -998,24 +1091,19 @@ fn installBattery(
}
}
- // Pass the driver source as the first arg, then the luarocks
- // CLI args. We use `lua_pushlstring` to push everything as
- // Lua strings (no NUL terminator needed, since lua_pushlstring
- // takes an explicit length).
+ // Pass the driver source as the first arg, then the luarocks CLI args.
+ // `lua_pushlstring` takes an explicit length (no NUL needed).
_ = c.lua_pushlstring(L, driver_slice.ptr, driver_slice.len);
- _ = c.lua_pushlstring(L, "install", 7);
- _ = c.lua_pushlstring(L, battery.name.ptr, battery.name.len);
- _ = c.lua_pushlstring(L, battery.version.ptr, battery.version.len);
-
- _ = allocator; // currently unused; reserved for future scratch needs
+ for (cli_args) |a| _ = c.lua_pushlstring(L, a.ptr, a.len);
- if (c.lua_pcallk(L, 4, 0, 0, 0, null) != 0) {
+ const nargs: c_int = @intCast(1 + cli_args.len);
+ if (c.lua_pcallk(L, nargs, 0, 0, 0, null) != 0) {
var len: usize = 0;
const msg = c.lua_tolstring(L, -1, &len);
if (msg != null) {
std.log.err(
- "panto: luarocks install of {s} {s} failed: {s}",
- .{ battery.name, battery.version, msg[0..len] },
+ "panto: luarocks install of '{s}' failed: {s}",
+ .{ label, msg[0..len] },
);
}
c.lua_settop(L, c.lua_gettop(L) - 1);
diff --git a/src/main.zig b/src/main.zig
index 2c5b414..86f1cb1 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -388,6 +388,17 @@ pub fn main(init: std.process.Init) !void {
// chance to register tools that might want to yield.
try rt.installScheduler();
+ // Install any user rocks (`extensions.rocks`) into the shared tree so the
+ // loader can `require` them below. Best-effort: a rock that fails to
+ // install is logged and skipped — the REPL still starts. This is where
+ // registry code enters, so it is the code-execution trust boundary; the
+ // allow/deny policy is a feature toggle, not a security gate.
+ for (app_config.ext_rocks) |spec| {
+ _ = luarocks_runtime.installRockIfMissing(luarocks_rt, alloc, io, spec.value) catch |err| {
+ std.log.err("extensions.rocks: failed to install '{s}': {t}", .{ spec.value, err });
+ };
+ }
+
// Discover Lua extensions across three layers — base
// (<data home>/agent), user ($XDG_CONFIG_HOME/panto or
// $HOME/.config/panto), and project (./.panto). Project shadows
@@ -399,10 +410,9 @@ 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,
- },
+ &app_config.extensions,
+ app_config.ext_paths,
+ app_config.ext_rocks,
) catch |err| {
std.log.err("extension discovery failed: {t}", .{err});
return err;
diff --git a/src/subcommand.zig b/src/subcommand.zig
index 0c04ce8..1372d72 100644
--- a/src/subcommand.zig
+++ b/src/subcommand.zig
@@ -77,6 +77,10 @@ pub fn dispatch(
try runBootstrapSubcommand(allocator, io, environ_map, panto_executable_path, .{ .force = force });
return .done;
}
+ if (std.mem.eql(u8, sub, "update")) {
+ try runUpdateSubcommand(allocator, io, environ_map, panto_executable_path);
+ return .done;
+ }
if (std.mem.eql(u8, sub, "sessions")) {
try runSessionsSubcommand(allocator, io, environ_map);
return .done;
@@ -116,6 +120,7 @@ fn printHelp(io: Io) !void {
\\ Forget a stored OAuth token.
\\ panto bootstrap [--force]
\\ Run the luarocks bootstrap and exit.
+ \\ panto update Install/update the rocks in extensions.rocks.
\\ panto lua [args...] Drop into the embedded Lua interpreter.
\\ panto help Show this message.
\\
@@ -124,9 +129,10 @@ fn printHelp(io: Io) !void {
\\ $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.
+ \\ [defaults] model = "<provider>:<alias>", and gate extensions with
+ \\ [extensions] allow/deny globs (plus [extensions] paths/rocks to add
+ \\ sources). Model aliases (wire name, reasoning, max_tokens, pricing)
+ \\ live in models.toml.
\\
\\Environment:
\\ OPENAI_API_KEY, ANTHROPIC_API_KEY Consumed by the default providers.
@@ -268,6 +274,64 @@ fn runBootstrapSubcommand(
}
// ---------------------------------------------------------------------------
+// `panto update`
+// ---------------------------------------------------------------------------
+
+/// `panto update` — (re)install every rock listed in `extensions.rocks`,
+/// unconditionally. This is the one place we intentionally hit luarocks (and
+/// the network): startup only installs a rock that is missing, so changing a
+/// pin within an already-satisfied range, or forcing a re-resolve, is done
+/// here. Analogous to `bootstrap`, but for user rocks rather than batteries.
+fn runUpdateSubcommand(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ panto_executable_path: []const u8,
+) !void {
+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const cwd_n = try std.process.currentPath(io, &cwd_buf);
+ const cwd = cwd_buf[0..cwd_n];
+
+ var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| {
+ std.log.err("panto update: failed to load config ({t})", .{err});
+ return err;
+ };
+ defer cfg.deinit();
+
+ var out_buf: [1024]u8 = undefined;
+ var out_file = std.Io.File.stdout().writer(io, &out_buf);
+ const out = &out_file.interface;
+
+ if (cfg.ext_rocks.len == 0) {
+ try out.writeAll("panto update: no extensions.rocks configured\n");
+ try out_file.flush();
+ return;
+ }
+
+ const L = c.luaL_newstate() orelse return error.LuaInitFailed;
+ defer c.lua_close(L);
+ c.luaL_openlibs(L);
+ const rt = try luarocks_runtime.bootstrap(allocator, io, environ_map, L, panto_executable_path);
+ defer rt.deinit();
+
+ var installed: usize = 0;
+ var failed: usize = 0;
+ for (cfg.ext_rocks) |spec| {
+ std.log.info("panto update: installing '{s}'", .{spec.value});
+ luarocks_runtime.installRock(rt, allocator, spec.value) catch |err| {
+ std.log.err("panto update: failed to install '{s}': {t}", .{ spec.value, err });
+ failed += 1;
+ continue;
+ };
+ installed += 1;
+ }
+
+ try out.print("panto update: {d} rock(s) installed, {d} failed\n", .{ installed, failed });
+ try out_file.flush();
+ if (failed > 0) return error.LuarocksInstallFailed;
+}
+
+// ---------------------------------------------------------------------------
// `panto sessions`
// ---------------------------------------------------------------------------