contenox
Browse docs/

The editor agent

The Contenox VS Code extension put the runtime inside the editor as a genuine workspace extension. Each VSIX bundled one native contenox binary for its target — Linux, macOS and Windows, x64 and ARM64 — and spawned it as a child process speaking header-framed JSON-RPC 2.0 over stdio, eighteen client-to-agent methods wide. No server, no port, no daemon: state lived in the same local database the CLI used. Because it declared itself a workspace extension, over SSH, WSL, Dev Containers and Codespaces it ran on the remote host beside the checked-out code rather than on the laptop driving the window. It registered as a native VS Code language-model vendor, so Contenox models appeared in the editor’s own model picker; it contributed an approve_contenox_tool_call language-model tool, an MCP server definition provider, an inline-completion provider bound to a deliberately separate FIM model from chat, diagnostics code actions behind the lightbulb, and around thirty-five palette commands. Telemetry was a local JSONL file and nothing else. Release engineering was treated as a correctness surface: a package guard failed the build on a VSIX containing zero or more than one native binary, a wrong extension id, a non-PNG icon, bundled sources or maps, or a missing approval-tool contribution; a verify job asserted package.json, the version file and the git tag agreed before any of the five platform targets were built; publishing sat behind a protected environment that required a human. CI ran the real Extension Development Host headless under xvfb on every pull request.

The interesting engineering was the approval boundary. A runtime HITL gate produced an approval request, and the design rule was that this had to be a blocking protocol operation, never a notification followed by a later response — because an editor that fails to render an approval must not leave the runtime free to keep reasoning and pick a different tool the policy happens to allow. One shared Go package built the request for both transports, so an approval looked identical whether it went to VS Code or to an ACP editor: a tool-call id, a title composed from the tool path and an eighty-rune argument summary, a kind mapped onto the protocol’s own read/edit/delete/move/execute/fetch vocabulary, a structured diff block whenever a before-and-after pair existed, and metadata naming the policy and the policy file that demanded the approval. Exactly two options were ever offered, both scoped to the single call: allow once and reject once. There was no “always allow”. The editor never learned the tool catalogue — policy and tool identity stayed in the runtime — and every failure path denied: a cancelled outcome, an unrecognised outcome, a missing handler, a closed connection, a cancellation token already fired.

Our own review of that bridge found the bug worth publishing. Permission handlers were held in a stack and dispatched last-in-first-wins, while a single connection served the chat panel and every chat editor tab. With two overlapping turns, a permission card could render in the wrong conversation bound to the wrong invocation token — a confused deputy, where the developer approves one turn’s action believing it belongs to another’s, reachable through nothing more exotic than two open tabs. The fix was to register handlers by session id and dispatch on an exact match, denying when no session matched. The same review deleted a security control rather than fixing it: a pending-permission guard had been ported from the ACP service, where events arrive asynchronously, into a bridge that publishes synchronously on the tool goroutine, where its window could never be open. Two mechanisms, one of them permanently inert, is worse than one — the blocking request had been the real enforcement all along. Abandonment was made symmetric with a $/cancelRequest in both directions so a runtime timeout could not leave a live modal on screen whose “Allow” would be silently swallowed, and both framers grew a 64 MiB bound on declared content length.

The Contenox VS Code extension icon

The same protocol implementation served editors that were never ours. contenox acp ran as an Agent Client Protocol agent under Zed, under JetBrains IDEs — verified against GoLand 2026.1.2, which does not advertise the protocol’s terminal capability, so shell output was reported in the tool card instead of a live embedded terminal — and under the AionUi desktop client. A separate contenox acpx profile served drivers with no human at an editor at all, replacing interactive approval with a static containment policy because prompts arriving from a chat inbox have nobody to ask. Packaging the agent for third-party distribution turned up a boot bug worth the exercise on its own: in a pristine home directory contenox acp printed a configuration error to stderr and emitted nothing on stdout, so a client saw a silent agent rather than a fixable one. The answer was a setup-only transport that still answered initialize and authenticate with no engine behind it, and an actionable typed error on session creation instead of a nil dereference.

Contenox also spoke the protocol in the other direction, driving external agents as a client. An external agent was registered by name and spawned over stdio inside a sandbox that scrubbed the environment and denied it ~/.contenox outright — an agent must never reach the policy that governs it. The one exemption, a chain unit re-invoking this very binary, could not be set by config or environment variable; it was inferred only from the command resolving to the running executable. Driving someone else’s agent taught a protocol lesson of its own: an observe-only client that answered a permission request with “method not found” was read by well-behaved agents as a broken client, and they derailed their reply into an error narrative rather than finishing the turn — contenox agent check hit exactly that. Replacing the non-answer with a clean, spec-shaped reject-once let the agent report the denial and end its turn on its own terms.

contenox agent check drove a registered agent end to end from the terminal — a real turn, its stop reason, the commands the agent advertised, and the MCP servers forwarded to it:

Terminal recording of contenox agent check streaming a reply from a registered external agent, then printing the turn's stop reason, the agent's advertised command list, and the MCP servers forwarded to it

Registered agents appeared beside the native one wherever a session started, so picking who answers was a normal part of opening a chat rather than a configuration exercise.

The agent picker open in the sessions sidebar, with the native Contenox agent at the top and registered external agents listed below it

Whatever slash commands an agent advertised over the protocol were proxied straight into the composer’s own menu.

The composer's slash-command menu listing commands advertised by the connected agent

A gated action from any agent — ours or a foreign one — stopped at the same inline permission card in the transcript, with Allow and Deny as the only exits — clicking outside it did nothing, so there was no accidental answer.

An inline permission card in the chat transcript showing the tool call awaiting a decision, with Allow and Deny buttons

What it proved

  • An approval must be a blocking protocol operation, not a notification. Modelling it as a reverse request the agent waits on is what makes the gate real; a fire-and-forget event leaves the runtime free to keep reasoning while the human is still deciding.
  • One approval builder, two transports, identical cards. The editor never learned the tool catalogue — same title, same tool kind, same diff block, same policy metadata, whether the answer came from VS Code’s native confirmation or from an ACP editor’s own UI.
  • Offering only once-scoped options was the right restriction. Allow-once and reject-once, with no “always”, meant no approval could quietly widen into a standing grant.
  • Concurrency turns an approval bug into a confused deputy. Last-handler-wins dispatch across two chat tabs could put a permission card in front of the wrong conversation; routing strictly by session id, and denying on no match, was the fix.
  • A security control that cannot fire is worse than none. A pending-permission guard ported into a synchronous transport could never open its window; deleting it and naming the blocking request as the enforcement left one mechanism instead of two.
  • A local-first agent has to boot cleanly into a bare home directory. Answering initialize from a setup-only transport, and failing session creation with a typed, actionable error, is the difference between an agent a client can diagnose and one that looks dead.
  • Confining a foreign agent means denying it the control plane. Scrubbing the environment is not enough — the agent’s sandbox excluded the runtime’s own state directory, so no agent could read or edit the policy deciding what it was allowed to do.
  • Refusing a protocol callback is not the same as declining it. Answering a permission request with “method not found” made agents treat the whole client as broken and narrate an error instead of finishing; a clean reject-once got a correct, reportable denial.

Esc to close