diff options
| -rw-r--r-- | docs/archive/agent-extensions-loading.md | 208 | ||||
| -rw-r--r-- | examples/extensions/echo.lua | 8 | ||||
| -rw-r--r-- | examples/extensions/greet.lua | 20 | ||||
| -rw-r--r-- | src/config_file.zig | 468 | ||||
| -rw-r--r-- | src/extension_loader.zig | 1181 | ||||
| -rw-r--r-- | src/glob.zig | 74 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 352 | ||||
| -rw-r--r-- | src/luarocks_runtime.zig | 122 | ||||
| -rw-r--r-- | src/main.zig | 18 | ||||
| -rw-r--r-- | src/subcommand.zig | 70 |
10 files changed, 1516 insertions, 1005 deletions
diff --git a/docs/archive/agent-extensions-loading.md b/docs/archive/agent-extensions-loading.md new file mode 100644 index 0000000..498f35d --- /dev/null +++ b/docs/archive/agent-extensions-loading.md @@ -0,0 +1,208 @@ +# Extension loading: unified policy, entry/activate, rocks & paths + +Status: **implemented** (2026-07-01). Governs the panto CLI's extension/tool +loading, the `[extensions]` config section, and loading luarocks packages and +extra filesystem paths as extension sources. + +## Implementation status + +Landed and covered by `zig build test` (666 tests green): + +- `src/glob.zig` — `Specificity` + `specificity()`, with tests. +- `src/config_file.zig` — single `[extensions]` section; `Policy` is now a + layered, specificity-sorted rule list resolved by last-match-wins; + `[tools]` removed; `extensions.paths`/`extensions.rocks` parsed per-layer + into `Config.ext_paths`/`ext_rocks`. +- `src/lua_runtime.zig` — `Entry` + `evalEntries`/`evalEntriesFromModule`/ + `activateEntry`/`dropEntry`; `loadExtension`/`loadTool` removed. Sugar tool + tables still work. +- `src/extension_loader.zig` — two-pass (eval → shadow → filter → activate); + `Source {layer, origin}` precedence (`rocks < paths < dir`, `project > user + > base`); scans dirs + `paths`, requires `rocks`. +- `src/luarocks_runtime.zig` — `installRock` / `installRockIfMissing` + (in-process luarocks driver, exact-pin fast-path skip). +- `src/main.zig` — installs `extensions.rocks` (best-effort) then discovers. +- `examples/extensions/{greet,echo}.lua` migrated; `agent/tools/*.lua` + unchanged (sugar). `wc.lua` unchanged. + +Known follow-ups / not yet done: + +- **Rocks end-to-end is unverified against a real registry rock** — no such + rock exists yet and the sandbox has no network. The `require`→entries path + is unit-tested via `package.preload`; the install path reuses the + battery-install machinery. First real integration test is pending the + published `agent.*` rock. +- **Integrity (#9)** — first-party GPG signing + `luarocks --verify` + embedded + pubkey is designed but NOT wired. Until then `rocks` pins *which version*, + not *which bytes*. The bespoke sha256 lockfile stays deferred (YAGNI). +- Rock module name is assumed equal to the rock name (spec's first token); + rocks whose Lua module differs would need a mapping. + +Context: a separately-published luarocks package (the `agent.*` suite — skills, +rules, subagents) will register extensions into panto. That suite lives in its +own repo/rock; this work is only the *loading mechanism* in panto core that +lets a rock (or an extra path) contribute extensions, plus the config surface +to select among them. + +## Decisions + +### 1. One `[extensions]` config section + +The old split of `[tools]` (gates registered tool names post-load) and +`[extensions]` (gates script logical names pre-load) collapses into a single +`[extensions]` section governing every lua-authored capability, whether it +comes from the `extensions/` dir, the `tools/` dir, a rock, or an extra path. +No backwards compatibility — `[tools]` is removed. + +```toml +[extensions] +allow = ["agent.rules"] +deny = ["agent.*"] +rocks = ["panto-agent 1.4.2-1"] +paths = ["./dev/my-extension"] +``` + +### 2. Registration redesign: entry + deferred `activate()` + +Every extension source (disk file or rock module) is **always eval'd** and +returns an *entry*, or a list of entries. An entry is `{ name, activate }`: + +```lua +return { + name = "agent.skills", -- identity; matched against allow/deny + activate = function() -- run ONLY if name is permitted + panto.ext.register_tool { ... } + panto.ext.register_command { ... } + end, +} +``` + +- `name` is the dotted identity the policy matches on. +- `activate()` is deferred: the runtime calls it only when `name` is permitted. + All `register_tool` / `register_command` / `on` side effects move inside it. +- A source returning a list registers multiple independently-gateable entries + (this is how one rock contributes many namespaces). +- **Sugar:** a returned table with a `handler` (and no `activate`) is a bare + tool — normalized internally to an entry whose activation registers itself. + The built-in `agent/tools/*.lua` keep working unchanged through this path. + +**Hard rule:** eval/`require` must be side-effect-free — it only *returns* +entries, never registers. This is what makes Pass 1 (below) safe to run over +*every* candidate, including denied ones. + +This collapses the old two-stage gate (pre-load logical name + post-load tool +name) into a single post-eval gate on the declared `name`. Identity is the +declared `name`, uniform across disk/rock/path — there is no filename-derived +identity anymore. + +### 3. Two-pass load + +- **Pass 1 — availability.** Walk sources in order, eval/`require` everything + (side-effect-free), collect `name → entry`. Later trumps earlier for the same + name (shadowing). Shadow key is the declared `name` (the old `(kind, name)` + key is gone — `kind` is dropped). +- **Pass 2 — activation.** Resolve the policy (below) and call `entry.activate()` + for every permitted name. + +Same-name duplicate **within one source** = error; **across sources** = shadow. + +### 4. Source precedence + +Cross-layer: `project > user > base` (unchanged). +Within a layer: `rocks < paths < dir` — the canonical `extensions/`/`tools/` +dir is most authoritative locally, an explicit `paths` entry next, a rock least +(most upstream). This lets a local dev copy shadow a packaged rock. + +### 5. Policy resolution: specificity + last-match-wins + +Neither clobber nor naive union across layers works (both silently drop another +layer's rules; and "carve one out of a denied group" needs allow to beat a +broader deny). The model: + +- Every layer contributes `allow`/`deny` glob patterns. **Rules are never + clobbered or merged away** — all layers' rules are kept. +- Build one ordered rule list: layers low→high; within a layer, sort + least-specific→most-specific, with `deny` after `allow` at equal specificity. +- Resolve a name by scanning the list and taking the **last** matching rule. + That yields: the highest layer that touches the name wins, and within it the + most-specific rule wins; a higher layer's broad rule beats a lower layer's + specific rule (consistent with panto's higher-layer-authoritative model). +- Default is **allow** (empty policy permits everything). A pure whitelist is + expressed explicitly as `deny = ["**"]` + specific `allow` carve-outs. + +**Specificity** (only compared when two patterns match the same name), in +order: (1) more literal segments win (`a.b.* > a.*`, `a.b.** > a.**`); +(2) more total segments win (`a.*.* > a.*`); (3) fewer `**` win (`a.* > a.**`). + +Example — base pins one thing out of an otherwise-denied group, user adds an +unrelated carve-out; both survive: + +```toml +# base +[extensions] +deny = ["agent.*"] +allow = ["agent.rules"] # more specific than agent.* → wins → agent.rules on +# user +[extensions] +allow = ["foo.bar"] # unions in, touches nothing else +``` + +### 6. `extensions.rocks` — the trust boundary + +`rocks` is a plain list of **luarocks dependency strings** passed straight +through to embedded luarocks. No custom grammar. + +```toml +rocks = ["panto-agent 1.4.2-1"] +``` + +- luarocks operators: `==` `~=` `<` `>` `<=` `>=` `~>`; no operator = implicit + `==`; `~>` is pessimistic ("compatible with"). +- **`rocks` is the code-execution trust boundary** — listing a rock is the + "I will run this" decision. `allow`/`deny` is a *feature toggle*, applied + after eval, and is **not** a security boundary (a hostile rock runs on + `require` regardless of any deny). +- Install via embedded luarocks into `<data-home>/rocks/...`. Startup only + installs a rock that is *missing* (exact pin: that version absent; range/bare: + no version of the name present) — never a per-startup network hit otherwise. + `panto update` force-(re)installs every `extensions.rocks` entry (the one + place upgrades/re-resolves happen). +- **Pin gotcha (verified in luarocks v3.13.0 `core/vers.lua`):** `1.4.2` + without the `-R` revision floats across `1.4.2-1`/`1.4.2-2` — the revision is + only compared when the constraint carries one. A true pin needs + `version-revision`. We do *not* enforce this; pin discipline lives in docs. + +### 7. `extensions.paths` — extra search dirs + +A plain list of filesystem dirs appended to the scan at the listing layer's +precedence. No registry — reuses the dir scan. Relative paths resolve against +cwd (project) for now. + +### 8. Integrity (deferred piece) + +luarocks provides **no** content-hash pinning: no sha256, no `--checksum`, only +an optional author-set `source.md5` for source archives. `--pin`/`luarocks.lock` +pins *versions, not bytes*. The only native integrity is GPG detached `.asc` +signatures via `luarocks install --verify` (plain gpg against the local +keyring; weak/optional in general). + +Plan: for the first-party `agent.*` rocks we publish, sign releases + embed our +public key in panto + install with `--verify` → a real boundary for first-party +code at low cost. Defer (YAGNI) a bespoke sha256 TOFU lockfile for unsigned +third-party rocks. Until that lands, docs must say `rocks` pins *which version*, +not *which bytes*. + +## Implementation notes (anchors) + +- `src/glob.zig` — add specificity comparator + rule-list resolution. +- `src/config_file.zig` — single `[extensions]` policy; capture per-layer + `allow`/`deny` in `loadFromPaths` (before the clobbering `mergeTable`), + build the ordered rule list. Drop `[tools]`. `Policy` becomes a rule list. +- `src/lua_runtime.zig` / `src/lua_bridge.zig` — new unified load path: eval + file, read returned entry/entries (or sugar tool table), hold `activate` + refs, activate permitted ones. Keep `register_tool`/`register_command`/`on`. +- `src/extension_loader.zig` — two-pass by declared name; `Source` gains + ordering for `rocks`/`paths`/dir; scan `paths`; resolve `rocks`. +- `src/luarocks_runtime.zig` / `src/manifest.zig` — install user `rocks`. +- `agent/tools/*.lua` — unchanged (sugar). `examples/extensions/*.lua` — + migrate to entry/activate. diff --git a/examples/extensions/echo.lua b/examples/extensions/echo.lua index 2f2d4de..112ee25 100644 --- a/examples/extensions/echo.lua +++ b/examples/extensions/echo.lua @@ -1,9 +1,11 @@ -- A trivial Lua tool that echoes back whatever the LLM asks it to. -- Useful for exercising the panto CLI's Lua extension path end-to-end. +-- +-- Single-tool sugar form: return a table with a `handler` and panto +-- registers it as a tool. Its `name` is the identity gated by the +-- `[extensions]` allow/deny policy. -local panto = require("panto") - -panto.ext.register_tool { +return { name = "echo", description = "Echo back the given message. Useful for testing whether tools work.", schema = { diff --git a/examples/extensions/greet.lua b/examples/extensions/greet.lua index d74095b..38d8ca0 100644 --- a/examples/extensions/greet.lua +++ b/examples/extensions/greet.lua @@ -2,17 +2,27 @@ -- Drop this under .panto/extensions/ (project) or your user config's -- extensions/ dir, then type `/greet` or `/greet <name>` in the REPL. -- +-- Extensions return an *entry* `{ name, activate }`. The file is always +-- eval'd, but `activate()` runs only if `name` is permitted by the +-- `[extensions]` policy — so registration is deferred into activate(). +-- One entry may register any number of tools/commands. +-- -- Slash-command handlers run synchronously and act by side effect (here, -- writing to stdout). `args` is the trimmed text after the command name; -- it is an empty string when none was given. The return value is ignored. local panto = require("panto") -panto.ext.register_command { +return { name = "greet", - description = "Print a greeting. Optional args name who to greet.", - handler = function(args) - local who = args ~= "" and args or "world" - io.write("\n[greet] hello, " .. who .. "!\n") + activate = function() + panto.ext.register_command { + name = "greet", + description = "Print a greeting. Optional args name who to greet.", + handler = function(args) + local who = args ~= "" and args or "world" + io.write("\n[greet] hello, " .. who .. "!\n") + end, + } end, } 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` // --------------------------------------------------------------------------- |
