-
Notifications
You must be signed in to change notification settings - Fork 3
Domains Extensions
The extensions domain is the harness contract for executable packages that contribute two things to Clio: command tools that run in a sandboxed operator runtime, and hook declarations that register into the middleware tier. A package is not executable merely by being installed. Every package passes through a strict manifest, a semver compatibility gate, a whole-tree integrity digest, and a loadability decision before any of its bytes can execute. The snapshot system then stamps a frozen generation so that extension resources and middleware hook registrations always move as a pair.
This domain is loaded by the orchestrator as a standard DomainModule. The manifest declares a dependency on the config domain (src/domains/extensions/manifest.ts), and createExtensionsBundle in src/domains/extensions/extension.ts binds an empty snapshot store at start() so that no reader can observe extension resources paired with hooks from another generation until the composition root publishes the boot generation.
A package root must contain one of three manifest filenames, checked in this order by findExtensionManifestPath in src/domains/extensions/discovery.ts:
const MANIFEST_NAMES = ["clio-coder-extension.yaml", "clio-coder-extension.yml", "clio-coder-extension.json"] as const;parseExtensionManifest enforces a closed key set. The allowed manifest keys are id, name, version, description, compatibility, capabilities, and runtime. Two additional sets are explicitly refused:
-
MANIFEST_KEYSrejects unknown keys asunknown manifest key '<key>'. -
PLUGIN_OWNED_KEYS—resources,skills,prompts,agents,fleets— produces the errorharness extensions cannot declare '<key>'; domain resources belong in a plugin: clio-coder library install <path>.
This is the boundary the contract test asserts: a manifest that carries resources: is invalid, and the refusal message names the plugin installer rather than reading as a typo. The test "refuses a domain resource declaration and names the plugin installer" in tests/contracts/extension-resources.test.ts iterates over all five plugin-owned keys and checks the exact message.
The required fields are id, version, and description. The id is validated by validateId against the regex /^[a-z0-9][a-z0-9._-]*[a-z0-9]$/ and a length cap of 80. The name falls back to id when absent.
Two separate declaration surfaces carry executable capability, and they are parsed by different modules:
-
capabilities.tools— model-facing command tools, parsed byparseExtensionCapabilitiesinsrc/domains/extensions/command-schema.ts. -
runtime— an operator-only runtime process, parsed byparseExtensionRuntimeinsrc/domains/extensions/runtime-schema.ts.
parseExtensionCapabilities accepts only tools (1 to 32 entries). Each tool is an ExtensionCommandTool:
export interface ExtensionCommandTool {
name: string;
description: string;
runtime: "node" | "python3";
entrypoint: string;
inputSchema: Record<string, unknown>;
timeoutMs?: number;
maxOutputBytes?: number;
}The tool is named for the model registry as extension_${id}__${name} (the extensionToolName helper). The qualified name must fit in 64 characters and use only provider-safe characters. The extension id itself cannot contain a double underscore, because that delimiter is structural.
inputSchema is validated by validateSchema, which accepts only a bounded JSON-schema keyword set (SCHEMA_KEYS), a finite type set (TYPES), and at most 12 nesting levels. Object schemas must declare properties and additionalProperties: false, and property names may not be __proto__, constructor, or prototype. The root must be of type object.
The runtime declaration is an ExtensionRuntimeDeclaration:
export interface ExtensionRuntimeDeclaration {
api: 1;
entrypoint: string;
commands: Array<{ name: string; description: string; timeoutMs: number }>;
events: Array<"session_open" | "turn_end">;
ui: Array<"status" | "panel">;
}parseExtensionRuntime requires api === 1, a .mjs entrypoint of at most 240 characters, command names matching /^[a-z][a-z0-9_]*$/ (unique, at most 32), and per-command timeouts of 100–300000 ms. The events and ui arrays are constrained to closed choice sets and cannot contain duplicates.
The contract test "runtime manifest validates declarations and contained entrypoints without running startup code" in tests/extended/operator-extensions.test.ts confirms that api: 2, an unknown field, a .ts entrypoint, a zero timeout, an undeclared event, and an undeclared ui capability all produce undefined manifests. It also confirms that discovery never executes package code: a manifest whose entrypoint throws is still reported as valid by loadManifestFromRoot.
The compatibility.clio field is a semver range evaluated against the running Clio version. evaluateClioCompatibility in src/domains/extensions/compatibility.ts returns { rangeValid, satisfied, runningVersion }.
The parser is deliberately bounded and fail-closed because manifests are untrusted input. satisfiesSemVerRange accepts ordinary npm range vocabulary — comparator sets, ||, exact and partial versions, wildcards, hyphen ranges, caret ranges, tilde ranges — but rejects tags and malformed or overlong expressions (over 256 characters) instead of treating them as compatible. The test "accepts a strict compatible manifest with no capabilities" asserts evaluateClioCompatibility(">=0.4.0 <0.5.0", "0.4.1") returns satisfied: true.
At manifest-parse time, an unsatisfied range produces the error extension <id> requires Clio '<range>', but running Clio version is '<running>'. At load time, evaluateClioCompatibility is consulted again and sets the compatible flag on the InstalledExtension record.
Every package is digested by extensionContentDigestWithCapture in src/domains/extensions/integrity.ts. The digest binds, in stable relative-path order: file names, node kinds (directory, link, file), symlink targets, empty directories, and file bytes. A digestFrame writes a header of the form kind\0relativePath\0byteLength\0 followed by the payload and a NUL terminator, so the framing is unambiguous even when one path is a prefix of another.
The function is hardened against TOCTOU races:
-
Hard links are refused (
nlink !== 1), so a file cannot be swapped for a shared inode without changing the digest. -
Symbolic links must resolve within the package root (
containedcheck); a link that escapes the root throwssymbolic link escapes the extension root. -
Files are opened with
O_NOFOLLOWand re-fstated after open and after read to detect mutation between inspection and hashing (extension file changed while being opened/hashed). -
Directories are re-stat'd after the listing to detect entry-set changes mid-walk (
assertStableDirectory). -
The root itself must not be a symlink (
extension root must be a directory, not a symbolic link).
The contract tests in tests/contracts/extension-resources.test.ts exercise these: "rejects hard-linked files from content identity and deactivates the package", "rejects a symbolic link swapped outside the package while being hashed" (injecting a readlinkSync override that repoints the link mid-digest), "frames file names, payloads, and symbolic-link targets without ambiguity" (two trees with swapped prefix/suffix split points produce different digests), and "keeps a package whose tree escapes its root visible but inactive".
src/domains/extensions/state.ts owns the on-disk install state and the admission decision. The state file lives at <base>/state.json per scope, where the base directory is:
- user scope:
<config-dir>/extensions - project scope:
<cwd>/.clio-coder/extensions
The ExtensionState schema is version 1 and records disabled ids plus an installed map keyed by id holding installedAt, optional source, and optional contentDigest. readState is fail-closed: corrupt or absent state returns a default state with a diagnostic, and a corrupt state marks every package in that scope as invalid.
installedFromRoot performs the admission decision for each package directory:
- It loads the manifest and computes the observed content digest via
extensionContentDigestWithCapture, capturing the manifest bytes andhooks.yaml. -
contentVerifiedis true only when the observed digest equals theexpectedDigestrecorded in install state. -
provenanceis constructed only when the content is verified, the expected digest is present, and the manifest bytes were captured. ThemanifestDigestis the SHA-256 of the captured manifest bytes. -
validrequires a present manifest, a valid candidate, andcontentVerified. -
compatibleis the compatibility evaluation result. -
loadableisvalid && compatible && enabled && effective.
The listInstalledExtensionRecords function resolves scope precedence: project scope (rank 2) wins over user scope (rank 1) for the same id. The winner is marked effective: true; losers that are otherwise valid are marked overriddenBy with the winner's scope. Invalid and incompatible packages remain visible (not filtered) so the load refusal and its diagnostic cannot disappear along with the suppressed capabilities.
The test "detects post-install content drift and prevents activation" in tests/contracts/extension-resources.test.ts writes hooks.yaml after install, then asserts drifted.valid === false, drifted.loadable === false, drifted.provenance === undefined, and a content drift detected diagnostic. The test "distinguishes absent install state from corrupt install state and fails closed" confirms both absent and corrupt state keep packages unloadable.
The snapshot is the unit of generation isolation. buildExtensionSnapshot in src/domains/extensions/snapshot.ts builds an ExtensionSnapshot for a given cwd and generation number:
- It lists installed records and, for each loadable package, captures the command tool names and the digest of the captured
hooks.yamlbytes. - It computes a content digest over a canonical-JSON projection of the packages (id, scope, loadable, provenance, commandTools, hooksDigest), excluding generation, timestamp, and diagnostic text.
- It deep-freezes the entire snapshot, so every consumer receives an immutable projection.
- Diagnostics are bounded: 20 per package, 200 globally, 512 characters per message (
EXTENSION_SNAPSHOT_DIAGNOSTIC_*_CAP).
diffExtensionSnapshots compares two snapshots by fingerprinting packages per id (scope, loadable, provenance, capabilities) and reporting added, removed, modified, and changed. changed is true when the content digests differ, even if no package list changed.
The createExtensionsBundle function in src/domains/extensions/extension.ts owns a single in-flight candidate and the snapshot store. The prepareReload contract:
- Rejects reentrant prepares (
reason: "reentrant"). - Reserves the next generation before building, so a discarded or failed candidate burns its number and generations stay monotonic.
- Builds the snapshot; a build failure returns
rejectedwithreason: "build-failed". - Returns a prepared candidate whose
current()returns true only while it is the in-flight candidate and the committed snapshot is still the previous one. -
publish()performs a single assignment; it never validates, refuses, throws, or calls out.
The contract test "reserves generations monotonically and publishes by plain assignment" in tests/extended/extension-snapshot.test.ts asserts that a discarded reservation burns a generation: after publishing generation 1, calling nextGeneration() twice returns 2 then 3, and the second reservation is burned.
The extensions bundle and the middleware bundle are independent, and neither knows the other exists. src/entry/extension-reload.ts is the composition-root coordinator that is the only writer of both for the "user-hooks" owner. It sequences a reload so no observer can see resources from one generation paired with hooks from another:
- Canonicalize the workspace once.
- Prepare the extension candidate; require its snapshot cwd to match the session workspace.
- Build user-hook registrations from the candidate's captured
hooks.yamldeclarations plus project files. - Prepare the middleware replacement.
- Confirm both prepared states are still current, then publish both with two assignment-only calls in adjacent statements.
- Only then emit conflicts, the reload event, and issue lines.
Neither publish primitive can refuse or throw, so a partial publication is impossible by construction. The test "publishes extension and middleware adjacently before diagnostic, event, or report re-entry" in tests/extended/extension-reload-coordinator.test.ts wraps both publish primitives to log entry and asserts the log sequence is ext-publish:1, mw-publish:1, with all observer callbacks strictly after.
extensionSnapshotFor in src/domains/extensions/snapshot-access.ts is the reader path: it returns the committed snapshot when a store is bound for the current cwd, otherwise an ephemeral generation-0 build. This is the path the CLI, config inspect, and any process that never booted the bundle take.
The operator runtime is the process boundary. OperatorExtensionRuntime in src/domains/extensions/operator-runtime.ts owns operator processes only — it is never used by native workers or model tool bootstrap. Each process is an ExtensionRuntimeProcess in src/domains/extensions/runtime-process.ts.
Private copy. The ExtensionRuntimeProcess constructor copies the installed package into a temporary directory (mkdtempSync), re-verifies the copy's digest against extension.provenance.contentDigest, re-parses the manifest from the captured bytes, and confirms the runtime declaration matches the verified manifest. If the digests differ, it throws runtime copy does not match installed package digest. The copy is cleaned up on exit and disposal.
Fork. The child is forked with runtime-child.mjs as the entrypoint, using a safe environment (buildSafeToolEnv), detached on POSIX, JSON serialization, and stdio: ["ignore", "pipe", "pipe", "ipc"]. The child stdout/stderr are consumed only for diagnostic byte accounting (64 KiB cap), never forwarded to a terminal.
IPC protocol. The protocol is protocol: 1 with messages init, activate, command, observe, result, error, fatal, cancel, dispose. The host validates every message's protocol and instance fields. A ready message must carry exactly the declared commands and events (sorted and compared). A result without its exact outstanding request id is ignored. The state machine is staging → starting → ready → failed/disposed.
API injection. The child's runtime-child.mjs imports the extension entrypoint and calls the default export with a frozen ExtensionApi:
export interface ExtensionApi {
readonly apiVersion: 1;
handle(name, handler): void;
on(event, handler): void;
onDispose(handler): void;
}Registration is gated: handlers can only be registered for declared commands and events, cannot be duplicated, and cannot be registered late. The factory receives a frozen snapshot and a frozen context per request. The test "real runtime stages before activation, filters env, keeps instance state and disposes private copy" in tests/extended/operator-extensions.test.ts confirms that a secret env var is null inside the child and the private copy is removed on dispose.
Invocation fencing. invoke requires an idle session before the request (isIdle() check), captures the context identity, and after the request re-verifies that the context identity, the process identity, and the process state are all unchanged. If the session changed, the process was replaced, or the process is no longer ready, the result is rejected with extension result belongs to a revoked runtime or session. The test "session switch during request fences old output and rebuilds context" confirms this: a session switch during an in-flight request rejects the result with disposed|revoked and a reload rebuilds the context with the new session id.
Process limits. RUNTIME_LIMITS in src/domains/extensions/runtime-schema.ts caps concurrent operator processes at 4. Excess eligible packages get the failure operator runtime limit is 4; disable another runtime and reload.
Separate from the operator runtime, model-facing command tools are registered by registerHarnessExtensionTools in src/tools/harness-extensions.ts. This path freezes installed tool schemas at registry construction: it re-verifies the package digest, parses the manifest from the captured bytes, and registers each tool as a ToolSpec with baseActionClass: "execute" and placement: "gateway". The tool is never imported or executed at registration; lifecycle mutations (disable, replace, remove) revoke calls immediately by re-checking the install state at invocation time.
The test "discovers declarative tools without executing package code" in tests/contracts/harness-extensions.test.ts confirms discovery writes no DISCOVERY_EXECUTED marker. The test "refuses disabled, replaced, removed, and drifted installed commands" confirms that disabling, mutating, force-reinstalling, and removing the extension each produce an error from the registered tool.
The operator-facing command path is the /ext:<id>:<command> slash command. parseSlashCommand in src/interactive/slash-commands.ts classifies it as an unknown command because ext: is not a reserved token. The dispatch branch then checks isExtensionCommandToken(command.token) (regex /^ext:[^\s/]+$/u in src/domains/extensions/operator-commands.ts), reads the operator runtime's command rows filtered by prompt names, and requires row.available to be true. It slices the trailing text after the token as args, and serializes the work through ctx.runLocalOperation so the next admitted slash command observes the committed state. Inside that local operation it calls runtime.invoke(command.token, args, promptNames), which forwards to OperatorExtensionRuntime.invoke.
The test "operator dispatch joins local admission and preserves unavailable drafts" in tests/extended/operator-extensions.test.ts demonstrates this: the dispatch returns "accepted" immediately, the invocation is deferred until the local-operation queue runs, and a disabled extension makes dispatchSlashCommand return "rejected" without starting a new operation.
The boot path composes the same reload coordinator. The orchestrator constructs createExtensionReloadCoordinator in src/entry/orchestrator.ts, passes the extensions and middleware contracts, and calls extensionReload.applyBoot() after the domain bundle starts. The coordinator samples the cwd once, prepares the extension candidate, builds user-hook registrations, prepares the middleware replacement, confirms both are current, then publishes both adjacently.
sequenceDiagram
participant Orch as Orchestrator
participant Coord as createExtensionReloadCoordinator
participant Ext as ExtensionsContract
participant MW as MiddlewareContract
participant Store as ExtensionSnapshotStore
participant Obs as Observers
Orch->>Ext: prepareReload()
Ext->>Ext: reserve nextGeneration()
Ext->>Store: build(generation)
Ext-->>Coord: candidate (prepared)
Coord->>MW: prepareRegistrationReplacement(user-hooks, gen, regs)
MW-->>Coord: replacement (prepared)
Coord->>Ext: candidate.current()
Coord->>MW: replacement.current()
Ext->>Store: publish(snapshot)
MW->>MW: publish(replacement)
Coord->>MW: emitConflicts()
Coord->>Obs: onCommitted(generation)
A change of the kind this area invites is made in these places:
-
New manifest key: add to
MANIFEST_KEYSinsrc/domains/extensions/discovery.ts, then parse and validate inparseExtensionManifest. Add a rejection case intests/contracts/extension-resources.test.ts. -
New command tool field: add to
TOOL_KEYSinsrc/domains/extensions/command-schema.ts, then validate inparseExtensionCapabilities. UpdatecreateCommandToolinsrc/tools/harness-extensions.tsto consume it. -
New runtime event: add to the
eventschoice set inparseExtensionRuntimeinsrc/domains/extensions/runtime-schema.ts, and to theExtensionEventtype insrc/domains/extensions/public-api.ts. -
New IPC message kind: add a field set in the
fieldsmap inExtensionRuntimeProcess.receiveinsrc/domains/extensions/runtime-process.ts, and handle it inruntime-child.mjs. -
New hook source type: captured in
buildExtensionSnapshotinsrc/domains/extensions/snapshot.tsand adapted incapturedHookSourcesForinsrc/entry/extension-hook-sources.ts. -
New scope: add to
ExtensionScopeinsrc/domains/extensions/types.ts, thescopeRankfunction insrc/domains/extensions/state.ts, and theextensionBaseDirpath logic.
-
The manifest key set is closed. Adding a key to
MANIFEST_KEYSwithout adding a corresponding rejection inrejectManifestKeyswill silently accept manifests that should fail. ThePLUGIN_OWNED_KEYSguidance string is load-bearing for the contract test. -
The digest framing is unambiguous by construction. Do not remove the
\0terminators or the byte-length header indigestFrame; they exist to prevent prefix-collision ambiguity between file names and payloads. The test"frames file names, payloads, and symbolic-link targets without ambiguity"guards this. -
The snapshot digest excludes generation and diagnostics. If you add a field to the canonical-JSON projection in
buildExtensionSnapshot, it will change the content digest and cause every reload to reportchanged: trueeven when nothing semantically changed. -
The reload coordinator publishes two independent references adjacently. Inserting any
awaitor callback betweencandidate.publish()andreplacement.publish()insrc/entry/extension-reload.tsbreaks the paired-publication invariant. The test"publishes extension and middleware adjacently before diagnostic, event, or report re-entry"asserts adjacency. -
The operator runtime never forwards child stdout/stderr. The
diagnosticcallback inExtensionRuntimeProcessaccumulates bytes up to 64 KiB and then fails the process. Do not add apipeto a terminal; it would leak unsanitized extension output. -
The IPC protocol validates every message field. The
fieldsmap inExtensionRuntimeProcess.receiveis the allowlist. Adding a new message kind without updating this map will cause the process to fail withinvalid runtime protocol fields. -
The snapshot store is bound per process, not per cwd.
bindExtensionSnapshotStoresets a module-level singleton. The test teardown intests/extended/extension-snapshot.test.tscallsbindExtensionSnapshotStore(null)inafterEach. Forgetting this will leak state between tests.
Source and generation metadata
title: "Domains extensions"
summary: "The harness extension contract: extensions declare command tools and hook declarations in a strict manifest, the operator runtime forks a private child process to inject the API, and a generation-stamped snapshot system pairs extension packages with middleware hooks so no reader sees state from two generations. Compatibility and integrity checks gate every package before it can run."
sources:
- "src/domains/extensions/types.ts"
- "src/domains/extensions/discovery.ts"
- "src/domains/extensions/command-schema.ts"
- "src/domains/extensions/compatibility.ts"
- "src/domains/extensions/integrity.ts"
- "src/domains/extensions/state.ts"
- "src/domains/extensions/extension.ts"
- "src/domains/extensions/snapshot.ts"
- "src/domains/extensions/snapshot-store.ts"
- "src/domains/extensions/snapshot-access.ts"
- "src/domains/extensions/operator-runtime.ts"
- "src/domains/extensions/runtime-process.ts"
- "src/domains/extensions/runtime-schema.ts"
- "src/domains/extensions/public-api.ts"
- "src/domains/extensions/operator-commands.ts"
- "src/domains/extensions/runtime-child.mjs"
- "src/domains/extensions/manager.ts"
- "src/domains/extensions/contract.ts"
- "src/entry/extension-reload.ts"
- "src/tools/harness-extensions.ts"
tests:
- "tests/contracts/extension-resources.test.ts"
- "tests/contracts/harness-extensions.test.ts"
- "tests/extended/extension-snapshot.test.ts"
- "tests/extended/extension-reload-coordinator.test.ts"
- "tests/extended/operator-extensions.test.ts"
invariants:
- "A harness extension manifest cannot declare resources, prompts, skills, agents, or fleets; those keys are refused and the message points to the plugin installer."
- "An extension is loadable only when its installed tree digest equals the digest recorded at install; any drift or missing install state makes it invalid, non-effective, and unloadable."
- "Project-scope extensions override user-scope extensions with the same id; the loser is marked with overriddenBy and its tools are not admitted."
- "A committed extension snapshot is deep-frozen; a reader for one cwd never sees the committed store of another cwd and instead gets an ephemeral generation-0 build."
- "prepareReload publishes nothing; the generation is reserved before the snapshot is built, so a discarded or failed candidate still burns its generation number and generations stay strictly monotonic."
- "The operator runtime forks the package into a private temporary copy whose digest is reverified against the provenance before the child is allowed to run."
validate:
- "pnpm test:file -- tests/contracts/extension-resources.test.ts"Clio Coder · Repository · Website · Documentation
Wiki v0.1 · Developing implementation reference · Source snapshot: 657dce13d. Authored architecture documents define the product contracts.
- Clio Coder GUI Client
- apps / clio-coder-gui
- Apps clio coder gui server
- Apps clio coder gui tests
- apps
- Architecture
- Command-line surfaces
- Core
- Domains agents
- Config Domain
- Context Domain
- Dispatch domain
- Domains evidence
- Domains extensions
- Domains gateway
- domains
- Domains interop
- Domains lifecycle
- Domains memory
- Middleware Domain
- Domains mux
- Domains observability
- Domains plugins
- Prompt Compiler
- Domains providers
- Domains quota
- Domains resources
- Domains safety
- Domains scheduling
- Domains session
- Vendored Tool Registry and Resolution
- Engine
- Engine acp
- Engine apis
- engine
- Entry point
- Interactive
- interactive
- Interactive overlays
- Interactive renderers
- clio-coder wiki
- Scripts
- Contract tests
- Tests extended
- tests
- Tools
- Tools data
- tools
- Tools verify
- Worker runtime