Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions docs/contracts/extension-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,25 @@ return {
return a table. Initialization failure rolls back registrations for that
extension and does not call its unload hook. Explicit unload invokes `unload`
once, then removes the extension's commands, keymap contributions and event
listeners. Runtime shutdown drops registrations without invoking extension
unload hooks; this preserves the stated current contract and avoids running
arbitrary extension code while the runtime is closing.
listeners, styles, themes and syntax contributions. Runtime shutdown drops
registrations without invoking extension unload hooks; this preserves the
stated current contract and avoids running arbitrary extension code while
the runtime is closing.

### Duplicate, unload and shutdown semantics

- Loading a name that is already active is idempotent: Runtime returns the
existing active record without resolving the entry again or re-running
`init`. There is no in-place hot reload; explicitly unload the extension
before loading that name again.
- If `unload()` raises, Runtime still removes the extension's owned commands,
keymaps, event listeners, styles, themes and syntax registrations and
removes the active record before returning the structured error. A later
load of the same name starts from a clean lifecycle state.
- Unloading an unknown name returns `false` and performs no work.
- Manager shutdown removes all active owner-scoped contributions and clears
the active set without invoking extension `unload()` hooks. Shutdown is
cleanup, not a sequence of user-visible explicit unload operations.

## Capability facade

Expand Down Expand Up @@ -70,8 +86,12 @@ For each extension, Backend resolves the entry module name `init` inside that
extension's namespace. Nested Lua `require("sub.module")` calls use the same
resolver and the same owning extension name. The resolver returns the exact Lua
source string and may return a virtual chunk name for diagnostics. It must
return `nil` for a module that does not exist and must not expose absolute host
paths in chunk names or error messages.
return `nil` for a module that does not exist. For access, I/O or limit
failures, it may raise a structured Runtime error in the `cdin.runtime` domain.
Runtime preserves that code when the resolver fails and through entry-module
execution, nested module loading and the extension `init` hook. Ordinary Lua
errors that are not recognized Runtime records remain `BAD_EXTENSION`.
Resolvers must not expose absolute host paths in chunk names or error messages.

Trusted embedders may use the following Runtime methods:

Expand Down
3 changes: 3 additions & 0 deletions lua/cdin/runtime/extension/sandbox.lua
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function Sandbox:_environment(plugin_name)
local ok, result = pcall(chunk)
self.active[module_name] = nil
if not ok then
if errors.is(result) then error(result, 0) end
errors.raise("BAD_EXTENSION", "extension module failed", {
module = module_name,
cause = tostring(result),
Expand Down Expand Up @@ -177,6 +178,7 @@ function Sandbox:load(plugin_name, source, core_facade, config, chunk_name)

local ok, module = pcall(chunk)
if not ok then
if errors.is(module) then error(module, 0) end
errors.raise("BAD_EXTENSION", "extension entry failed", {
plugin = plugin_name,
cause = tostring(module),
Expand All @@ -201,6 +203,7 @@ function Sandbox:load(plugin_name, source, core_facade, config, chunk_name)
if module.init then
local init_ok, init_error = pcall(module.init, core_facade, config)
if not init_ok then
if errors.is(init_error) then error(init_error, 0) end
errors.raise("BAD_EXTENSION", "extension init failed", {
plugin = plugin_name,
cause = tostring(init_error),
Expand Down
167 changes: 162 additions & 5 deletions tests/integration/run.lua
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,10 @@ A.falsy(runtime.syntax:get("plugin-sample") ~= nil)
local command_takeover = [[return { init = function(core)
core.commands:register("document.undo", function() return "hijacked" end, { overwrite = true })
end }]]
A.error_code(function() runtime.plugins:load("takeover", command_takeover, {}) end, "BAD_EXTENSION")
local takeover_error = A.error_code(function() runtime.plugins:load("takeover", command_takeover, {}) end, "PERMISSION_DENIED")
A.equal(takeover_error.domain, "cdin.runtime")
A.equal(takeover_error.details.command, "document.undo")
A.equal(takeover_error.details.owner, "core")
A.truthy(runtime.commands:get("document.undo"))
A.equal(runtime.commands:get("document.undo").owner, "core")
local failed_plugin = [[return { init = function(core)
Expand Down Expand Up @@ -245,10 +248,17 @@ A.error_code(function()
end, "INVALID_ARGUMENT")
local resolved = resolver_runtime:load_extension("sample-resolved", { suffix = "!" })
A.equal(resolved.name, "sample-resolved")
-- A duplicate load is idempotent: it returns the active record without
-- resolving modules again or re-running init with new configuration.
local resolver_calls_before_duplicate = #resolver_calls
local duplicate_resolved = resolver_runtime:load_extension("sample-resolved", { suffix = "ignored" })
A.equal(duplicate_resolved.name, resolved.name)
A.equal(duplicate_resolved.state, "active")
A.equal(#resolver_calls, resolver_calls_before_duplicate)
A.equal(resolved.state, "active")
A.equal(resolved.source, "resolver")
A.equal(resolver_runtime.commands:execute("resolved.greeting", {}, {}), true)
local _, resolved_value = resolver_runtime.commands:execute("resolved.greeting", {}, {})
local resolved_ok, resolved_value = resolver_runtime.commands:execute("resolved.greeting", {}, {})
A.truthy(resolved_ok)
A.equal(resolved_value, "hello!")
A.equal(resolver_calls[1], "sample-resolved:init")
A.equal(resolver_calls[2], "sample-resolved:helpers.greeting")
Expand All @@ -261,7 +271,7 @@ A.equal(#resolver_runtime:list_extensions(), 0)
local module_path_escape = [[return { init = function() require("../outside") end }]]
A.error_code(function()
resolver_runtime.plugins:load("module-escape", module_path_escape, {})
end, "BAD_EXTENSION")
end, "PERMISSION_DENIED")
A.falsy(resolver_runtime.commands:get("module-escape.command"))

-- Dotted module identifiers must accept ordinary segments and reject empty
Expand All @@ -280,13 +290,160 @@ for index, module_name in ipairs(invalid_module_names) do
local resolver_calls_before = #resolver_calls
A.error_code(function()
resolver_runtime.plugins:load(plugin_name, source, {})
end, "BAD_EXTENSION")
end, "PERMISSION_DENIED")
A.equal(#resolver_calls, resolver_calls_before)
A.falsy(resolver_runtime.commands:get(plugin_name .. ".command"))
end

-- An unload-hook failure still releases every owner-scoped contribution and
-- removes the name from the active set. The structured failure is returned
-- only after cleanup has completed.
local unload_failure_runtime = Runtime.new({
host = FakeHost.new(),
module_resolver = function()
return [[return {
init = function(core)
core.commands:register("unload.failure.command", function() return true end)
end,
unload = function()
error({
domain = "cdin.runtime",
code = "PERMISSION_DENIED",
message = "extension cleanup was denied",
details = { stage = "unload" },
})
end,
}]]
end,
})
unload_failure_runtime:start()
unload_failure_runtime:load_extension("unload-failure", {})
A.truthy(unload_failure_runtime.commands:get("unload.failure.command"))
local unload_error = A.error_code(function()
unload_failure_runtime:unload_extension("unload-failure")
end, "PERMISSION_DENIED")
A.equal(unload_error.details.stage, "unload")
A.equal(#unload_failure_runtime:list_extensions(), 0)
A.equal(unload_failure_runtime.commands:get("unload.failure.command"), nil)
A.falsy(unload_failure_runtime:unload_extension("unload-failure"))
A.truthy(unload_failure_runtime:shutdown())

-- Manager shutdown drops owner registrations without invoking extension
-- unload hooks. A hook that would fail must not turn shutdown into a failure.
local shutdown_extension_runtime = Runtime.new({
host = FakeHost.new(),
module_resolver = function()
return [[return {
init = function(core)
core.commands:register("shutdown.extension.command", function() return true end)
end,
unload = function() error("shutdown must not invoke extension unload") end,
}]]
end,
})
shutdown_extension_runtime:start()
shutdown_extension_runtime:load_extension("shutdown-extension", {})
A.truthy(shutdown_extension_runtime.commands:get("shutdown.extension.command"))
A.truthy(shutdown_extension_runtime:shutdown())
A.equal(shutdown_extension_runtime.plugins.plugins["shutdown-extension"], nil)
A.equal(shutdown_extension_runtime.commands:get("shutdown.extension.command"), nil)

A.truthy(resolver_runtime:shutdown())

-- Structured Backend resolver failures must survive nested require and
-- extension initialization instead of being flattened to BAD_EXTENSION.
local structured_error_runtime = Runtime.new({
host = FakeHost.new(),
module_resolver = function(_, module_name)
if module_name == "init" then
return [[return { init = function() require("helpers.too_large") end }]]
end
error({
domain = "cdin.runtime",
code = "LIMIT_EXCEEDED",
message = "extension source exceeds configured size",
details = { module = module_name },
})
end,
})
structured_error_runtime:start()
local nested_resolver_error = A.error_code(function()
structured_error_runtime:load_extension("oversized-extension", {})
end, "LIMIT_EXCEEDED")
A.equal(nested_resolver_error.domain, "cdin.runtime")
A.equal(nested_resolver_error.details.module, "helpers.too_large")
A.equal(#structured_error_runtime:list_extensions(), 0)
A.truthy(structured_error_runtime:shutdown())


-- A structured resolver failure while resolving the entry source must retain
-- its Backend code and details before any extension is registered.
local structured_entry_resolver_runtime = Runtime.new({
host = FakeHost.new(),
module_resolver = function()
error({
domain = "cdin.runtime",
code = "IO_ERROR",
message = "extension entry could not be read",
details = { module = "init" },
})
end,
})
structured_entry_resolver_runtime:start()
local entry_resolver_error = A.error_code(function()
structured_entry_resolver_runtime:load_extension("entry-read-failure", {})
end, "IO_ERROR")
A.equal(entry_resolver_error.domain, "cdin.runtime")
A.equal(entry_resolver_error.details.module, "init")
A.equal(#structured_entry_resolver_runtime:list_extensions(), 0)
A.truthy(structured_entry_resolver_runtime:shutdown())

-- Structured errors raised by the entry chunk and init hook must cross the
-- sandbox boundary unchanged; failed init still rolls back owned contributions.
local structured_entry_runtime = Runtime.new({
host = FakeHost.new(),
module_resolver = function()
return [[error({
domain = "cdin.runtime",
code = "IO_ERROR",
message = "entry execution failed",
details = { stage = "entry" },
})]]
end,
})
structured_entry_runtime:start()
local entry_error = A.error_code(function()
structured_entry_runtime:load_extension("entry-execution-failure", {})
end, "IO_ERROR")
A.equal(entry_error.details.stage, "entry")
A.equal(#structured_entry_runtime:list_extensions(), 0)
A.truthy(structured_entry_runtime:shutdown())

local structured_init_runtime = Runtime.new({
host = FakeHost.new(),
module_resolver = function()
return [[return {
init = function(core)
core.commands:register("structured.init.partial", function() return true end)
error({
domain = "cdin.runtime",
code = "PERMISSION_DENIED",
message = "extension initialization was denied",
details = { stage = "init" },
})
end,
}]]
end,
})
structured_init_runtime:start()
local init_error = A.error_code(function()
structured_init_runtime:load_extension("init-permission-failure", {})
end, "PERMISSION_DENIED")
A.equal(init_error.details.stage, "init")
A.falsy(structured_init_runtime.commands:get("structured.init.partial"))
A.equal(#structured_init_runtime:list_extensions(), 0)
A.truthy(structured_init_runtime:shutdown())

local missing_resolver_runtime = Runtime.new({
host = FakeHost.new(),
module_resolver = function() return nil end,
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/run.lua
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,10 @@ local module = sandbox:load("safe", "return { init = function(core, config) core
commands = { register = function(_, name) A.equal(name, "safe.ok") end }, keymap = {}, events = {}, log = function() end, ui = {},
}, {})
A.equal(type(module.init), "function")
A.error_code(function() sandbox:load("unsafe", "return { init = function() return os.execute('x') end }", {}, {}) end, "BAD_EXTENSION")
A.error_code(function() sandbox:load("global", "leak = true; return {}", {}, {}) end, "BAD_EXTENSION")
-- Accessing a forbidden global is a capability denial, not a generic malformed
-- extension. Preserve the sandbox's stable authorization error code.
A.error_code(function() sandbox:load("unsafe", "return { init = function() return os.execute('x') end }", {}, {}) end, "PERMISSION_DENIED")
A.error_code(function() sandbox:load("global", "leak = true; return {}", {}, {}) end, "PERMISSION_DENIED")

require("unit.log_tests")

Expand Down
Loading