diff --git a/README.md b/README.md index b655ea4..94ca618 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ platform/architecture companion artifacts beside the ZIP. Convax verifies their size and SHA-256 into host-owned storage, so users do not install a sidecar through `PATH` and executables still never enter a Plugin package. +`convax.plugin/5` additionally declares an LLM provider as bounded provider/model +metadata. The verified sidecar supplies a random, Main-only loopback gateway at +runtime; manifests and service projections never contain upstream URLs, Cookies, +headers, or credentials. + `convax.plugin/4` adds Plugin-owned Skills. A v4 Plugin declares `contributes.skills`, and the packer injects each referenced standard Skill workspace into the Plugin ZIP. Convax may show that Skill in its catalog, but its diff --git a/bun.lock b/bun.lock index f40b8c6..3706cd8 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,13 @@ "name": "@microvoid/convax-plugins-registry", "version": "0.1.0" }, + "packages/plugins/codex-service": { + "name": "@microvoid/convax-plugin-codex-service", + "version": "0.1.1", + "dependencies": { + "@microvoid/convax-codex-mcp": "workspace:*" + } + }, "packages/plugins/ffmpeg-tools": { "name": "@microvoid/convax-plugin-ffmpeg-tools", "version": "0.3.1", @@ -85,6 +92,17 @@ "name": "@microvoid/convax-skill-video-prompting", "version": "0.3.0" }, + "packages/tools/codex-mcp": { + "name": "@microvoid/convax-codex-mcp", + "version": "0.1.1", + "bin": { + "convax-codex-mcp": "dist/convax-codex-mcp" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + } + }, "packages/tools/ffmpeg-mcp": { "name": "@microvoid/convax-ffmpeg-mcp", "version": "0.2.0", @@ -144,9 +162,15 @@ {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==" ], + "@microvoid/convax-codex-mcp": [ + "@microvoid/convax-codex-mcp@workspace:packages/tools/codex-mcp" + ], "@microvoid/convax-ffmpeg-mcp": [ "@microvoid/convax-ffmpeg-mcp@workspace:packages/tools/ffmpeg-mcp" ], + "@microvoid/convax-plugin-codex-service": [ + "@microvoid/convax-plugin-codex-service@workspace:packages/plugins/codex-service" + ], "@microvoid/convax-plugin-ffmpeg-tools": [ "@microvoid/convax-plugin-ffmpeg-tools@workspace:packages/plugins/ffmpeg-tools" ], diff --git a/docs/packaging.md b/docs/packaging.md index 2b7a219..ef1d4b1 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -26,7 +26,7 @@ STORE method. Thus identical source bytes produce identical SHA-256 digests acro machines. Uncompressed storage is intentional: packages are already size-bounded, and avoiding compressor-version drift makes releases reproducible. -A headless `convax.plugin/2`, `convax.plugin/3`, or `convax.plugin/4` Tool Plugin may contain only `manifest.json` and a +A headless `convax.plugin/2` through `convax.plugin/5` Tool Plugin may contain only `manifest.json` and a license notice. Its generation and/or service contribution uses one declared `mcp-stdio` executable that is a separate distributable and must never appear anywhere below `package/`; validation and packing do not install, diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index 657f68f..ce7a3af 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -5,9 +5,9 @@ served through a private protocol and mounted in an iframe with exactly `sandbox="allow-scripts"`. It has an opaque origin: it cannot inspect the parent DOM, use browser storage as shared application state, or access Node/Electron. -`convax.plugin/2`, `convax.plugin/3`, and `convax.plugin/4` may instead be headless +`convax.plugin/2` through `convax.plugin/5` may instead be headless executable Tool Plugins. New executable Plugins should use v3, or v4 when they own -Skills. Their ZIP still contains no executable code: the manifest names a separately distributed bare +Skills; use v5 only when they contribute an LLM provider. Their ZIP still contains no executable code: the manifest names a separately distributed bare `mcp-stdio` command for generation and/or fixed service actions. The Registry may bind that command to verified platform artifacts that Convax installs into host-owned storage. Explicit Plugin install/update authorizes that exact binding; @@ -16,13 +16,14 @@ later tool calls do not add another local-command prompt. ## Manifest `package/manifest.json` uses `convax.plugin/1`, `convax.plugin/2`, -`convax.plugin/3`, or `convax.plugin/4`. Only +`convax.plugin/3`, `convax.plugin/4`, or `convax.plugin/5`. Only documented fields are accepted. Source metadata must use the matching pair: - `convax.plugin/1` with `convax.plugin-host/1`; - `convax.plugin/2` with `convax.plugin-host/2`; - `convax.plugin/3` with `convax.plugin-host/3`. - `convax.plugin/4` with `convax.plugin-host/4`. +- `convax.plugin/5` with `convax.plugin-capability/1`. The v1 schema is static-only: @@ -182,7 +183,7 @@ closed and require reinstall. The Plugin manifest never contains build paths, vendor credentials, or a fallback download URL, and the user does not need to copy the executable into `PATH`. -A v2, v3, or v4 Web surface that calls installed generation tools requests +A v2 through v5 Web surface that calls installed generation tools requests `generation.execute` and uses an ordinary `entry` plus Canvas contribution. It may omit `runtime` and `contributes.generation`. Declaring a runtime does not grant the Web surface caller authority, and granting `generation.execute` does not let the @@ -190,7 +191,7 @@ iframe start processes or send arbitrary MCP requests. ## Plugin service contribution -A v2, v3, or v4 executable Plugin may expose bounded account/service state through the same +A v2 through v5 executable Plugin may expose bounded account/service state through the same verified sidecar process used by generation. The manifest declares only which fixed host actions are meaningful; it cannot choose MCP method names or attach an action payload: @@ -235,11 +236,45 @@ to the matching authorization generation, never return it through MCP, and clear it on sign-out. Mode `0600` is best-effort isolation from other OS users; it does not protect against processes already running as the same OS account. +## LLM provider contribution + +`convax.plugin/5` may contribute one OpenAI-compatible provider through the same +verified sidecar lifecycle. The manifest contains display and selection metadata +only: + +```json +{ + "contributes": { + "llm": { + "provider": { "id": "example-llm", "name": "Example LLM" }, + "models": [{ "id": "example-main", "name": "Example Main" }] + } + } +} +``` + +The sidecar must expose the fixed, empty-input MCP tool `llm.gateway.start`. Its +Main-only `structuredContent` is exactly `{schema, base_url, api_key}` with schema +`convax.llm-gateway/1`, an ephemeral `http://127.0.0.1:/v1` URL, and a random +process-lifetime key. The gateway accepts only authenticated OpenAI-compatible +requests for declared models. It owns upstream URLs, headers, credentials, Cookies, +streaming, cancellation, and vendor error adaptation; none of those values belongs +in the manifest, renderer, service status, or durable OpenCode config. + +Hosts namespace provider ids by Plugin identity, verify the installed executable +before starting it, and discard the gateway when that exact Plugin runtime changes. +An unavailable or invalid gateway is omitted rather than weakening loopback or +executable verification. + +The v5 compatibility pair deliberately uses the independently versioned +`convax.plugin-capability/1` broker. It does not extend the legacy iframe +`convax.plugin-host/N` sequence. + ## Host connection Convax transfers one fresh `MessagePort` to each mounted Plugin node. Accept it only from `window.parent`, for the host protocol matching the manifest major (`/1`, -`/2`, `/3`, or `/4`), the exact Plugin id, and only once: +`/2`, `/3`, `/4`, or `/5`), the exact Plugin id, and only once: ```js const PROTOCOL = "convax.plugin-host/1"; diff --git a/docs/registry-spec.md b/docs/registry-spec.md index 8d6b679..82867be 100644 --- a/docs/registry-spec.md +++ b/docs/registry-spec.md @@ -13,7 +13,7 @@ catalog-changing release or yanking deployment. `revision` is the lowercase, ful Every item contains `kind`, `id`, `name`, `description`, `version`, `compatibility`, `artifact`, and `yanked`, plus a complete `manifest` for Plugin items. -A `convax.plugin/2`, `convax.plugin/3`, or `convax.plugin/4` item with a generation and/or service external runtime may additionally contain +A `convax.plugin/2` through `convax.plugin/5` item with an external runtime may additionally contain `companions`; no other item may contain it. The duplicated Plugin identity fields must equal the manifest so the management UI can render and filter without downloading ZIPs. Skill items have no `manifest`. @@ -49,7 +49,8 @@ complete validated manifest. Plugin compatibility accepts exactly one version-matched pair: `convax.plugin/1` + `convax.plugin-host/1`, `convax.plugin/2` + `convax.plugin-host/2`, `convax.plugin/3` + `convax.plugin-host/3`, or -`convax.plugin/4` + `convax.plugin-host/4`. The embedded manifest schema must match +`convax.plugin/4` + `convax.plugin-host/4`, or +`convax.plugin/5` + `convax.plugin-capability/1`. The embedded manifest schema must match that pair. Crossed pairs and a v1 compatibility envelope around a v2 manifest are rejected. Skill compatibility is exactly `{"skillSchema":"opencode.skill/1"}`. Artifact objects contain only `url`, `size`, and lowercase hex `sha256`; URLs always diff --git a/packages/plugins/codex-service/convax-package.json b/packages/plugins/codex-service/convax-package.json new file mode 100644 index 0000000..4e9ce2a --- /dev/null +++ b/packages/plugins/codex-service/convax-package.json @@ -0,0 +1,28 @@ +{ + "schema": "convax.package/1", + "kind": "plugin", + "id": "codex-service", + "name": "Codex", + "description": "Binds an existing local Codex installation and account to Convax for GPT-5.6, GPT-5.5, and GPT Image 2 without copying Codex credentials.", + "version": "0.1.1", + "license": "MIT", + "compatibility": { + "pluginSchema": "convax.plugin/5", + "pluginHost": "convax.plugin-capability/1" + }, + "companions": [ + { + "command": "convax-codex-mcp", + "version": "0.1.1", + "source": "packages/tools/codex-mcp", + "targets": [ + { + "platform": "darwin", + "arch": "arm64", + "path": "dist/darwin-arm64/convax-codex-mcp" + } + ] + } + ], + "yanked": false +} diff --git a/packages/plugins/codex-service/package.json b/packages/plugins/codex-service/package.json new file mode 100644 index 0000000..093bbf7 --- /dev/null +++ b/packages/plugins/codex-service/package.json @@ -0,0 +1,13 @@ +{ + "name": "@microvoid/convax-plugin-codex-service", + "version": "0.1.1", + "private": true, + "type": "module", + "dependencies": { + "@microvoid/convax-codex-mcp": "workspace:*" + }, + "scripts": { + "validate": "bun ../../../tooling/validate.mjs --kind plugin --id codex-service", + "pack": "bun ../../../tooling/pack.mjs --kind plugin --id codex-service" + } +} diff --git a/packages/plugins/codex-service/package/LICENSE b/packages/plugins/codex-service/package/LICENSE new file mode 100644 index 0000000..0260f10 --- /dev/null +++ b/packages/plugins/codex-service/package/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Microvoid contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugins/codex-service/package/manifest.json b/packages/plugins/codex-service/package/manifest.json new file mode 100644 index 0000000..3890160 --- /dev/null +++ b/packages/plugins/codex-service/package/manifest.json @@ -0,0 +1,39 @@ +{ + "schema": "convax.plugin/5", + "id": "codex-service", + "name": "Codex", + "description": "Binds an existing local Codex installation and account to Convax for GPT-5.6, GPT-5.5, and GPT Image 2 without copying Codex credentials.", + "version": "0.1.1", + "contributes": { + "generation": { + "models": [ + { "tool": "image.gpt-image-2", "name": "GPT Image 2" } + ], + "tools": [ + { + "id": "image.gpt-image-2", + "title": "Codex · GPT Image 2", + "description": "Generate or edit an image through the bound local Codex account and its built-in GPT Image 2 capability.", + "output": "image", + "acceptedInputs": ["reference_image"] + } + ] + }, + "llm": { + "provider": { "id": "codex", "name": "Codex" }, + "models": [ + { "id": "gpt-5.6-sol", "name": "GPT-5.6-Sol" }, + { "id": "gpt-5.6-terra", "name": "GPT-5.6-Terra" }, + { "id": "gpt-5.6-luna", "name": "GPT-5.6-Luna" }, + { "id": "gpt-5.5", "name": "GPT-5.5" } + ] + }, + "service": { + "actions": ["authorize", "reauthorize"] + } + }, + "runtime": { + "type": "mcp-stdio", + "command": "convax-codex-mcp" + } +} diff --git a/packages/tools/codex-mcp/.gitignore b/packages/tools/codex-mcp/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/packages/tools/codex-mcp/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/tools/codex-mcp/AGENTS.md b/packages/tools/codex-mcp/AGENTS.md new file mode 100644 index 0000000..d229a71 --- /dev/null +++ b/packages/tools/codex-mcp/AGENTS.md @@ -0,0 +1,36 @@ +# Local Codex companion contract + +This directory owns the separately installed `convax-codex-mcp` companion for +the `codex-service` Plugin. The executable is not part of the Plugin ZIP. + +- Bind only to a user-installed Codex executable and its documented app-server + protocol. Never read, copy, return, or persist Codex tokens, cookies, config, + rollout files, or SQLite state directly. +- Keep the Codex child on stdio. The only network listener is the random-key, + loopback-only OpenAI-compatible gateway returned to Convax Main by + `llm.gateway.start`. +- Service status may use only `account/read`, `account/rateLimits/read`, + `model/list`, and `modelProvider/capabilities/read`. Return unavailable fields + honestly and never expose native paths, raw Codex errors, or full rate-limit + payloads through MCP. +- `service.authorize` and `service.reauthorize` only re-probe the existing local + Codex binding. They never log the user in, log the user out, open a browser, or + mutate Codex configuration. +- Accept only the declared GPT-5.6 and GPT-5.5 model ids at the gateway and verify + that the bound Codex catalog currently exposes the requested model. +- Translate caller tools through Codex dynamic tools. Never execute them in the + companion. Interrupt the ephemeral Codex turn after capturing a tool call and + return that call to the OpenAI-compatible caller. +- GPT Image 2 generation uses Codex's built-in `imageGeneration` capability. Read + only host-staged reference-image paths and write final artifacts only beneath + the supplied `output_directory`. +- Bound handshake, metadata, and individual protocol requests. Once an LLM or + image turn starts, keep it alive until completion, explicit failure, child exit, + or caller cancellation; do not impose an arbitrary overall deadline. +- Human diagnostics go only to stderr and must not include credentials, account + email addresses, native paths, prompts, model output, or raw upstream bodies. +- The release build writes only `dist/darwin-arm64/convax-codex-mcp`; it must never + be copied under the Plugin package directory. + +Run `bun run typecheck`, `bun test`, `bun run build`, and +`bun run build:release:darwin-arm64` before handoff. diff --git a/packages/tools/codex-mcp/README.md b/packages/tools/codex-mcp/README.md new file mode 100644 index 0000000..8997e8b --- /dev/null +++ b/packages/tools/codex-mcp/README.md @@ -0,0 +1,16 @@ +# Convax Codex companion + +`convax-codex-mcp` binds the Convax Codex Plugin to an existing local Codex +installation. It starts Codex app-server over stdio, reuses Codex-managed login +state without reading credentials, and exposes only the fixed Convax MCP tools. + +Supported catalog entries are `gpt-5.6-sol`, `gpt-5.6-terra`, +`gpt-5.6-luna`, `gpt-5.5`, and the built-in GPT Image 2 generation capability. +The actual model and image capability are checked against the bound Codex process +at runtime. Account and quota display degrades to unavailable when Codex does not +provide the corresponding metadata. + +The first release target is macOS arm64. The locator checks official application +bundle locations and a real executable named `codex` on the absolute host `PATH`; +it validates `codex --version` before starting app-server. Configure or reconnect +means re-probing that local installation. Login and logout remain owned by Codex. diff --git a/packages/tools/codex-mcp/package.json b/packages/tools/codex-mcp/package.json new file mode 100644 index 0000000..47fa64b --- /dev/null +++ b/packages/tools/codex-mcp/package.json @@ -0,0 +1,21 @@ +{ + "name": "@microvoid/convax-codex-mcp", + "version": "0.1.1", + "private": true, + "license": "MIT", + "type": "module", + "bin": { + "convax-codex-mcp": "dist/convax-codex-mcp" + }, + "scripts": { + "build": "bun build --compile --minify --outfile dist/convax-codex-mcp src/index.ts", + "build:release:darwin-arm64": "bun build --compile --minify --target=bun-darwin-arm64 --outfile dist/darwin-arm64/convax-codex-mcp src/index.ts", + "clean": "rm -rf dist", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + } +} diff --git a/packages/tools/codex-mcp/src/app-server-client.ts b/packages/tools/codex-mcp/src/app-server-client.ts new file mode 100644 index 0000000..2d2b1cc --- /dev/null +++ b/packages/tools/codex-mcp/src/app-server-client.ts @@ -0,0 +1,231 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" + +const maximumMessageBytes = 64 * 1024 * 1024 +const startupTimeoutMs = 10_000 +const requestTimeoutMs = 10_000 + +interface PendingRequest { + reject(error: Error): void + resolve(value: unknown): void + timeout?: ReturnType +} + +export interface AppServerMessage { + error?: unknown + id?: number | string + method?: string + params?: unknown + result?: unknown +} + +export interface AppServerRequestContext { + id: number | string + method: string + params: unknown +} + +export type AppServerRequestHandler = ( + request: AppServerRequestContext, +) => Promise<{ handled: true; result: unknown } | { handled: false }> | { handled: true; result: unknown } | { handled: false } + +export interface AppServerClientOptions { + environment?: NodeJS.ProcessEnv + spawnProcess?: (executable: string, args: string[], environment: NodeJS.ProcessEnv) => ChildProcessWithoutNullStreams +} + +function publicProtocolError(value: unknown) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const message = (value as Record).message + if (typeof message === "string" && message.length > 0 && message.length <= 1_000) { + return new Error(message) + } + } + return new Error("Local Codex app-server request failed") +} + +function abortError(reason?: unknown) { + const error = reason instanceof Error ? reason : new Error("Local Codex request was cancelled") + error.name = "AbortError" + return error +} + +export class CodexAppServerClient { + readonly #environment: NodeJS.ProcessEnv + readonly #executable: string + readonly #listeners = new Set<(message: AppServerMessage) => void>() + readonly #pending = new Map() + readonly #requestHandlers = new Set() + readonly #spawnProcess: NonNullable + #buffer = "" + #child: ChildProcessWithoutNullStreams | undefined + #closed = false + #nextId = 1 + #starting: Promise | undefined + + constructor(executable: string, options: AppServerClientOptions = {}) { + this.#executable = executable + this.#environment = options.environment ?? process.env + this.#spawnProcess = options.spawnProcess ?? ((command, args, environment) => spawn(command, args, { + env: environment, + stdio: ["pipe", "pipe", "pipe"], + })) + } + + onMessage(listener: (message: AppServerMessage) => void) { + this.#listeners.add(listener) + return () => this.#listeners.delete(listener) + } + + onRequest(handler: AppServerRequestHandler) { + this.#requestHandlers.add(handler) + return () => this.#requestHandlers.delete(handler) + } + + async start() { + if (this.#closed) throw new Error("Local Codex app-server client is closed") + return await (this.#starting ??= this.#start()) + } + + async #start() { + const child = this.#spawnProcess( + this.#executable, + ["app-server", "--listen", "stdio://"], + { ...this.#environment }, + ) + this.#child = child + child.stdout.setEncoding("utf8") + child.stderr.on("data", () => undefined) + child.stdout.on("data", (chunk: string) => this.#receive(chunk)) + child.once("error", () => this.#fail(new Error("Local Codex app-server could not start"))) + child.once("exit", () => this.#fail(new Error("Local Codex app-server exited"))) + await this.#requestWithoutStart("initialize", { + capabilities: { experimentalApi: true }, + clientInfo: { + name: "convax-codex-service", + title: "Convax Codex Service", + version: "0.1.1", + }, + }, { timeoutMs: startupTimeoutMs }) + this.#write({ method: "initialized" }) + } + + async request( + method: string, + params?: unknown, + options: { signal?: AbortSignal | undefined; timeoutMs?: number | null } = {}, + ) { + await this.start() + return await this.#requestWithoutStart(method, params, options) + } + + async #requestWithoutStart( + method: string, + params?: unknown, + options: { signal?: AbortSignal | undefined; timeoutMs?: number | null } = {}, + ) { + if (options.signal?.aborted) throw abortError(options.signal.reason) + const id = this.#nextId++ + const response = new Promise((resolve, reject) => { + const pending: PendingRequest = { reject, resolve } + const timeoutMs = options.timeoutMs === undefined ? requestTimeoutMs : options.timeoutMs + if (timeoutMs !== null) { + pending.timeout = setTimeout(() => { + this.#pending.delete(id) + reject(new Error("Local Codex app-server request timed out")) + }, timeoutMs) + } + this.#pending.set(id, pending) + }) + const onAbort = () => { + const pending = this.#pending.get(id) + if (!pending) return + this.#pending.delete(id) + if (pending.timeout) clearTimeout(pending.timeout) + pending.reject(abortError(options.signal?.reason)) + } + options.signal?.addEventListener("abort", onAbort, { once: true }) + try { + this.#write({ id, method, ...(params === undefined ? {} : { params }) }) + return await response + } finally { + options.signal?.removeEventListener("abort", onAbort) + } + } + + #write(value: unknown) { + if (!this.#child || this.#closed) throw new Error("Local Codex app-server is unavailable") + const message = `${JSON.stringify(value)}\n` + if (Buffer.byteLength(message, "utf8") > maximumMessageBytes) { + throw new Error("Local Codex app-server request exceeded the message size limit") + } + this.#child.stdin.write(message) + } + + #receive(chunk: string) { + this.#buffer += chunk + if (Buffer.byteLength(this.#buffer, "utf8") > maximumMessageBytes) { + this.#fail(new Error("Local Codex app-server response exceeded the message size limit")) + return + } + while (true) { + const newline = this.#buffer.indexOf("\n") + if (newline < 0) return + const line = this.#buffer.slice(0, newline).trim() + this.#buffer = this.#buffer.slice(newline + 1) + if (!line) continue + let message: AppServerMessage + try { + message = JSON.parse(line) as AppServerMessage + } catch { + this.#fail(new Error("Local Codex app-server returned malformed JSON")) + return + } + if (message.id !== undefined && message.method === undefined) { + const id = typeof message.id === "number" ? message.id : Number(message.id) + const pending = Number.isSafeInteger(id) ? this.#pending.get(id) : undefined + if (!pending) continue + this.#pending.delete(id) + if (pending.timeout) clearTimeout(pending.timeout) + if (message.error !== undefined) pending.reject(publicProtocolError(message.error)) + else pending.resolve(message.result) + } else if (message.id !== undefined && typeof message.method === "string") { + void this.#handleServerRequest(message.id, message.method, message.params) + } else if (typeof message.method === "string") { + for (const listener of this.#listeners) listener(message) + } + } + } + + async #handleServerRequest(id: number | string, method: string, params: unknown) { + try { + for (const handler of this.#requestHandlers) { + const response = await handler({ id, method, params }) + if (response.handled) { + this.#write({ id, result: response.result }) + return + } + } + const result = method === "item/tool/call" + ? { contentItems: [{ text: "Tool execution is unavailable.", type: "inputText" }], success: false } + : { decision: "decline" } + this.#write({ id, result }) + } catch { + this.#write({ error: { code: -32_603, message: "Local Codex client rejected the request" }, id }) + } + } + + #fail(error: Error) { + if (this.#closed) return + this.#closed = true + this.#child?.kill("SIGKILL") + for (const pending of this.#pending.values()) { + if (pending.timeout) clearTimeout(pending.timeout) + pending.reject(error) + } + this.#pending.clear() + } + + close() { + this.#fail(new Error("Local Codex app-server client closed")) + } +} diff --git a/packages/tools/codex-mcp/src/chat-completions.ts b/packages/tools/codex-mcp/src/chat-completions.ts new file mode 100644 index 0000000..f730b5d --- /dev/null +++ b/packages/tools/codex-mcp/src/chat-completions.ts @@ -0,0 +1,300 @@ +import { randomUUID } from "node:crypto" +import type { AppServerMessage, CodexAppServerClient } from "./app-server-client.ts" +import { asRecord, codexLlmModelIds, type CodexLlmModelId } from "./contracts.ts" + +const maximumMessages = 4_096 +const maximumTextCharacters = 2_000_000 +const dynamicToolNamePattern = /^[A-Za-z0-9_-]{1,128}$/u + +export class InvalidChatCompletionError extends Error {} + +interface ChatMessage { + content?: unknown + role: string + tool_call_id?: unknown + tool_calls?: unknown +} + +export interface ChatCompletionRequest { + messages: ChatMessage[] + model: CodexLlmModelId + reasoningEffort?: string + stream: boolean + streamIncludeUsage: boolean + toolChoice?: unknown + tools: Array<{ description: string; inputSchema: Record; name: string }> +} + +export interface ChatCompletionToolCall { + arguments: string + id: string + name: string +} + +export interface ChatCompletionResult { + content: string + toolCalls: ChatCompletionToolCall[] + usage?: { completion_tokens: number; prompt_tokens: number; total_tokens: number } +} + +function textContent(value: unknown, label: string): string { + if (value === null || value === undefined) return "" + if (typeof value === "string") return value + if (!Array.isArray(value)) throw new InvalidChatCompletionError(`${label} content is invalid`) + let result = "" + for (const part of value) { + const item = asRecord(part, `${label} content item`) + if ((item.type === "text" || item.type === "input_text" || item.type === "output_text") && typeof item.text === "string") { + result += item.text + } else if (item.type === "image_url") { + const image = typeof item.image_url === "string" ? item.image_url : asRecord(item.image_url, "image URL").url + if (typeof image !== "string" || !image.startsWith("data:image/")) { + throw new InvalidChatCompletionError("only inline image data URLs are supported") + } + result += `\n[Inline image omitted from text transcript: ${image.slice(0, image.indexOf(",") + 1)}…]\n` + } else { + throw new InvalidChatCompletionError(`${label} content item is not supported`) + } + } + return result +} + +function parseTools(value: unknown) { + if (value === undefined) return [] + if (!Array.isArray(value) || value.length > 128) throw new InvalidChatCompletionError("tools are invalid") + const names = new Set() + return value.map((raw, index) => { + const tool = asRecord(raw, `tool ${index}`) + const fn = asRecord(tool.function, `tool ${index} function`) + if (tool.type !== "function" || typeof fn.name !== "string" || !dynamicToolNamePattern.test(fn.name) || names.has(fn.name)) { + throw new InvalidChatCompletionError("tool definition is invalid") + } + names.add(fn.name) + const inputSchema = fn.parameters === undefined ? {} : asRecord(fn.parameters, `tool ${index} parameters`) + return { + description: typeof fn.description === "string" ? fn.description.slice(0, 1_024) : "", + inputSchema, + name: fn.name, + } + }) +} + +export function parseChatCompletionRequest(value: unknown): ChatCompletionRequest { + const input = asRecord(value, "chat completion request") + if (typeof input.model !== "string" || !codexLlmModelIds.has(input.model)) { + throw new InvalidChatCompletionError("requested model is not supported") + } + if (!Array.isArray(input.messages) || input.messages.length === 0 || input.messages.length > maximumMessages) { + throw new InvalidChatCompletionError("messages are invalid") + } + let textCharacters = 0 + const messages = input.messages.map((raw, index) => { + const inputMessage = asRecord(raw, `message ${index}`) + if (typeof inputMessage.role !== "string" || !["system", "developer", "user", "assistant", "tool"].includes(inputMessage.role)) { + throw new InvalidChatCompletionError(`message ${index} role is invalid`) + } + const message: ChatMessage = { + role: inputMessage.role, + ...(inputMessage.content === undefined ? {} : { content: inputMessage.content }), + ...(inputMessage.tool_call_id === undefined ? {} : { tool_call_id: inputMessage.tool_call_id }), + ...(inputMessage.tool_calls === undefined ? {} : { tool_calls: inputMessage.tool_calls }), + } + textCharacters += textContent(message.content, `message ${index}`).length + return message + }) + if (textCharacters > maximumTextCharacters) throw new InvalidChatCompletionError("messages are too large") + const effort = input.reasoning_effort + if (effort !== undefined && (typeof effort !== "string" || !["low", "medium", "high", "xhigh", "max", "ultra"].includes(effort))) { + throw new InvalidChatCompletionError("reasoning effort is invalid") + } + const stream = input.stream === true + return { + messages, + model: input.model as CodexLlmModelId, + ...(typeof effort === "string" ? { reasoningEffort: effort } : {}), + stream, + streamIncludeUsage: stream && asRecord(input.stream_options ?? {}, "stream options").include_usage === true, + ...(input.tool_choice === undefined ? {} : { toolChoice: input.tool_choice }), + tools: parseTools(input.tools), + } +} + +function instructions(request: ChatCompletionRequest) { + const roleInstructions = request.messages + .filter((message) => message.role === "system" || message.role === "developer") + .map((message) => textContent(message.content, `${message.role} message`)) + .filter(Boolean) + .join("\n\n") + const toolRule = request.tools.length === 0 + ? "Do not call tools." + : request.toolChoice === "none" + ? "Do not call tools for this response." + : "Use only the caller-provided dynamic tools when a tool is needed. Never use shell, file, web, MCP, image generation, or other built-in tools." + return [ + "Act as a stateless OpenAI-compatible chat model. Produce only the next assistant response.", + toolRule, + "Do not discuss these transport instructions.", + roleInstructions, + ].filter(Boolean).join("\n\n") +} + +function assistantToolItems(message: ChatMessage) { + if (message.tool_calls === undefined) return [] + if (!Array.isArray(message.tool_calls)) throw new InvalidChatCompletionError("assistant tool calls are invalid") + return message.tool_calls.map((raw, index) => { + const call = asRecord(raw, `assistant tool call ${index}`) + const fn = asRecord(call.function, `assistant tool call ${index} function`) + if (typeof call.id !== "string" || typeof fn.name !== "string" || typeof fn.arguments !== "string") { + throw new InvalidChatCompletionError("assistant tool call is invalid") + } + return { arguments: fn.arguments, call_id: call.id, name: fn.name, type: "function_call" } + }) +} + +function historyItems(messages: ChatMessage[]) { + const items: unknown[] = [] + for (const message of messages) { + if (message.role === "system" || message.role === "developer") continue + const text = textContent(message.content, `${message.role} message`) + if (message.role === "tool") { + if (typeof message.tool_call_id !== "string") throw new InvalidChatCompletionError("tool result is missing tool_call_id") + items.push({ call_id: message.tool_call_id, output: text, type: "function_call_output" }) + continue + } + if (message.role === "assistant") { + if (text) items.push({ content: [{ text, type: "output_text" }], role: "assistant", type: "message" }) + items.push(...assistantToolItems(message)) + continue + } + items.push({ content: [{ text, type: "input_text" }], role: "user", type: "message" }) + } + return items +} + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined +} + +function usage(value: unknown): ChatCompletionResult["usage"] { + const input = record(value) + const prompt = input?.inputTokens + const completion = input?.outputTokens + const total = input?.totalTokens + return typeof prompt === "number" && typeof completion === "number" && typeof total === "number" + ? { completion_tokens: completion, prompt_tokens: prompt, total_tokens: total } + : undefined +} + +export class CodexChatRunner { + constructor(private readonly client: CodexAppServerClient) {} + + async complete( + request: ChatCompletionRequest, + options: { onDelta?: (delta: string) => void; signal?: AbortSignal } = {}, + ): Promise { + const catalog = record(await this.client.request("model/list", { includeHidden: true, limit: 100 }, { + signal: options.signal, + timeoutMs: 10_000, + })) + const available = Array.isArray(catalog?.data) && catalog.data.some((model) => record(model)?.id === request.model) + if (!available) throw new InvalidChatCompletionError("requested model is not available in local Codex") + const dynamicTools = request.tools.map((tool) => ({ + deferLoading: false, + description: tool.description, + inputSchema: tool.inputSchema, + name: tool.name, + type: "function", + })) + const started = record(await this.client.request("thread/start", { + approvalPolicy: "never", + baseInstructions: instructions(request), + cwd: process.cwd(), + developerInstructions: "Never mutate files or execute commands for this API request.", + dynamicTools, + environments: [], + ephemeral: true, + experimentalRawEvents: true, + model: request.model, + modelProvider: "openai", + sandbox: "read-only", + selectedCapabilityRoots: [], + serviceName: "convax_codex_gateway", + }, { signal: options.signal, timeoutMs: 15_000 })) + const thread = record(started?.thread) + if (typeof thread?.id !== "string") throw new Error("Local Codex did not start an ephemeral thread") + const threadId = thread.id + const items = historyItems(request.messages) + if (items.length > 0) { + await this.client.request("thread/inject_items", { items, threadId }, { signal: options.signal, timeoutMs: 15_000 }) + } + let content = "" + let finalUsage: ChatCompletionResult["usage"] + const toolCalls: ChatCompletionToolCall[] = [] + let activeTurnId: string | undefined + let resolveCompleted!: (message: AppServerMessage) => void + const completed = new Promise((resolve) => { resolveCompleted = resolve }) + const unsubscribeMessage = this.client.onMessage((message) => { + const params = record(message.params) + if (params?.threadId !== threadId) return + if (message.method === "item/agentMessage/delta" && typeof params.delta === "string") { + content += params.delta + options.onDelta?.(params.delta) + } else if (message.method === "item/completed") { + const item = record(params.item) + if (item?.type === "agentMessage" && typeof item.text === "string" && content.length === 0) content = item.text + } else if (message.method === "rawResponse/completed") { + finalUsage = usage(params.usage) ?? finalUsage + } else if (message.method === "turn/completed") { + const turn = record(params.turn) + if (!activeTurnId || turn?.id === activeTurnId) resolveCompleted(message) + } + }) + const unsubscribeRequest = this.client.onRequest(async (serverRequest) => { + if (serverRequest.method !== "item/tool/call") return { handled: false } + const params = record(serverRequest.params) + if (params?.threadId !== threadId || typeof params.callId !== "string" || typeof params.tool !== "string") { + return { handled: false } + } + toolCalls.push({ + arguments: JSON.stringify(params.arguments ?? {}), + id: params.callId, + name: params.tool, + }) + void this.client.request("turn/interrupt", { threadId, turnId: params.turnId }, { timeoutMs: 5_000 }).catch(() => undefined) + return { + handled: true, + result: { contentItems: [{ text: "Tool execution was delegated to the API caller.", type: "inputText" }], success: false }, + } + }) + const onAbort = () => { + if (activeTurnId) { + void this.client.request("turn/interrupt", { threadId, turnId: activeTurnId }, { timeoutMs: 5_000 }).catch(() => undefined) + } + } + options.signal?.addEventListener("abort", onAbort, { once: true }) + try { + const turnStart = record(await this.client.request("turn/start", { + ...(request.reasoningEffort === undefined ? {} : { effort: request.reasoningEffort }), + input: [{ text: "Continue from the supplied conversation and produce the next assistant response only.", text_elements: [], type: "text" }], + threadId, + }, { signal: options.signal, timeoutMs: 15_000 })) + const turn = record(turnStart?.turn) + if (typeof turn?.id !== "string") throw new Error("Local Codex did not start the completion turn") + activeTurnId = turn.id + const completedMessage = await completed + const completedTurn = record(record(completedMessage.params)?.turn) + if (completedTurn?.status === "failed" || (completedTurn?.status === "interrupted" && toolCalls.length === 0)) { + throw new Error("Local Codex completion failed") + } + return { content, toolCalls, ...(finalUsage === undefined ? {} : { usage: finalUsage }) } + } finally { + options.signal?.removeEventListener("abort", onAbort) + unsubscribeRequest() + unsubscribeMessage() + } + } +} + +export function completionId() { + return `chatcmpl_${randomUUID().replaceAll("-", "")}` +} diff --git a/packages/tools/codex-mcp/src/codex-locator.ts b/packages/tools/codex-mcp/src/codex-locator.ts new file mode 100644 index 0000000..5751d38 --- /dev/null +++ b/packages/tools/codex-mcp/src/codex-locator.ts @@ -0,0 +1,90 @@ +import { constants as fsConstants } from "node:fs" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { spawn } from "node:child_process" + +const versionTimeoutMs = 5_000 +const maximumVersionBytes = 8 * 1024 + +export interface CodexLocatorOptions { + environment?: Readonly> + platform?: NodeJS.Platform + probeVersion?: (executable: string) => Promise +} + +function pathCandidates(environment: Readonly>) { + const pathValue = environment.PATH + if (!pathValue) return [] + return pathValue + .split(path.delimiter) + .filter((entry) => path.isAbsolute(entry)) + .map((entry) => path.join(entry, "codex")) +} + +function applicationCandidates(environment: Readonly>, platform: NodeJS.Platform) { + if (platform !== "darwin") return [] + const home = environment.HOME || os.homedir() + return [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/Codex.app/Contents/Resources/codex", + ...(path.isAbsolute(home) + ? [ + path.join(home, "Applications/ChatGPT.app/Contents/Resources/codex"), + path.join(home, "Applications/Codex.app/Contents/Resources/codex"), + ] + : []), + ] +} + +export async function probeCodexVersion(executable: string) { + return await new Promise((resolve) => { + const child = spawn(executable, ["--version"], { + env: { LANG: "C", LC_ALL: "C" }, + stdio: ["ignore", "pipe", "ignore"], + }) + let output = "" + let settled = false + const finish = (valid: boolean) => { + if (settled) return + settled = true + clearTimeout(timeout) + resolve(valid) + } + const timeout = setTimeout(() => { + child.kill("SIGKILL") + finish(false) + }, versionTimeoutMs) + child.stdout.setEncoding("utf8") + child.stdout.on("data", (chunk: string) => { + output += chunk + if (Buffer.byteLength(output, "utf8") > maximumVersionBytes) { + child.kill("SIGKILL") + finish(false) + } + }) + child.once("error", () => finish(false)) + child.once("exit", (code) => finish(code === 0 && /^codex-cli\s+\d+\.\d+\.\d+/m.test(output))) + }) +} + +export async function resolveCodexExecutable(options: CodexLocatorOptions = {}) { + const environment = options.environment ?? process.env + const platform = options.platform ?? process.platform + const probeVersion = options.probeVersion ?? probeCodexVersion + const seen = new Set() + for (const candidate of [...applicationCandidates(environment, platform), ...pathCandidates(environment)]) { + try { + const executable = await fs.realpath(candidate) + if (seen.has(executable)) continue + seen.add(executable) + const stat = await fs.stat(executable) + if (!stat.isFile()) continue + await fs.access(executable, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK) + if (await probeVersion(executable)) return executable + } catch { + // Missing, unreadable, and invalid candidates are not valid bindings. + } + } + throw new Error("A compatible local Codex installation was not found") +} diff --git a/packages/tools/codex-mcp/src/contracts.ts b/packages/tools/codex-mcp/src/contracts.ts new file mode 100644 index 0000000..3edb5eb --- /dev/null +++ b/packages/tools/codex-mcp/src/contracts.ts @@ -0,0 +1,155 @@ +import path from "node:path" + +export const generationCallSchema = "convax.generation-call/1" as const +export const pluginServiceStatusSchema = "convax.plugin-service-status/1" as const +export const llmGatewaySchema = "convax.llm-gateway/1" as const + +export const codexLlmModels = [ + { id: "gpt-5.6-sol", name: "GPT-5.6-Sol" }, + { id: "gpt-5.6-terra", name: "GPT-5.6-Terra" }, + { id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }, + { id: "gpt-5.5", name: "GPT-5.5" }, +] as const + +export type CodexLlmModelId = typeof codexLlmModels[number]["id"] +export const codexLlmModelIds: ReadonlySet = new Set(codexLlmModels.map((model) => model.id)) +export const codexImageToolId = "image.gpt-image-2" as const + +export interface JsonRpcRequest { + id?: number | string | null + jsonrpc: "2.0" + method: string + params?: unknown +} + +export interface GenerationReference { + kind: "file" + mime_type: string + name: string + node_id: string + path: string + role: "reference_image" +} + +export interface GenerationCall { + operation_id: string + output: "image" + output_directory: string + prompt: string + references: GenerationReference[] + schema: typeof generationCallSchema +} + +export interface GenerationArtifact { + mimeType: string + name: string + path: string +} + +export interface PluginServiceStatus { + account: + | { availability: "available"; displayName: string } + | { availability: "unavailable" } + credential: { + configured: boolean + verification: "verified" | "unverified" | "failed" | "unknown" + } + credits: + | { availability: "available"; remaining: number; unit: string } + | { availability: "unavailable" } + schema: typeof pluginServiceStatusSchema + state: "connected" | "disconnected" | "attention" | "unknown" + usage: + | { availability: "available"; consumed: number; period?: string; unit: string } + | { availability: "unavailable" } +} + +export interface LlmGatewayDescriptor { + api_key: string + base_url: string + schema: typeof llmGatewaySchema +} + +export interface ToolResult { + content: Array<{ text: string; type: "text" }> + isError?: boolean + structuredContent?: + | { artifacts: GenerationArtifact[] } + | LlmGatewayDescriptor + | PluginServiceStatus +} + +export function asRecord(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`) + } + return value as Record +} + +function exactKeys(value: Record, keys: readonly string[], label: string) { + const expected = new Set(keys) + if (Object.keys(value).length !== keys.length || Object.keys(value).some((key) => !expected.has(key))) { + throw new Error(`${label} contains unsupported fields`) + } +} + +function trimmedString(value: unknown, label: string, maximumLength: number) { + if ( + typeof value !== "string" + || value.length === 0 + || value.length > maximumLength + || value !== value.trim() + || /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw new Error(`${label} is invalid`) + } + return value +} + +export function parseGenerationCall(value: unknown): GenerationCall { + const input = asRecord(value, "generation call") + exactKeys( + input, + ["schema", "operation_id", "prompt", "output", "output_directory", "references"], + "generation call", + ) + if (input.schema !== generationCallSchema || input.output !== "image") { + throw new Error("generation call contract is not supported") + } + const outputDirectory = trimmedString(input.output_directory, "generation output directory", 4_096) + if (!path.isAbsolute(outputDirectory)) throw new Error("generation output directory must be absolute") + if (!Array.isArray(input.references) || input.references.length > 16) { + throw new Error("generation references are invalid") + } + const nodeIds = new Set() + const references = input.references.map((value, index) => { + const reference = asRecord(value, `generation reference ${index}`) + exactKeys(reference, ["kind", "mime_type", "name", "node_id", "path", "role"], `generation reference ${index}`) + if (reference.kind !== "file" || reference.role !== "reference_image") { + throw new Error("generation reference must be a staged reference image") + } + const mimeType = trimmedString(reference.mime_type, "generation reference MIME type", 255) + const referencePath = trimmedString(reference.path, "generation reference path", 4_096) + const nodeId = trimmedString(reference.node_id, "generation reference node id", 256) + if (!mimeType.startsWith("image/") || !path.isAbsolute(referencePath) || nodeIds.has(nodeId)) { + throw new Error("generation reference is invalid") + } + nodeIds.add(nodeId) + return { + kind: "file" as const, + mime_type: mimeType, + name: trimmedString(reference.name, "generation reference name", 255), + node_id: nodeId, + path: referencePath, + role: "reference_image" as const, + } + }) + return { + operation_id: trimmedString(input.operation_id, "generation operation id", 256), + output: "image", + output_directory: outputDirectory, + prompt: trimmedString(input.prompt, "generation prompt", 20_000), + references, + schema: generationCallSchema, + } +} diff --git a/packages/tools/codex-mcp/src/image-generator.ts b/packages/tools/codex-mcp/src/image-generator.ts new file mode 100644 index 0000000..a4955e3 --- /dev/null +++ b/packages/tools/codex-mcp/src/image-generator.ts @@ -0,0 +1,152 @@ +import fs from "node:fs/promises" +import path from "node:path" +import type { CodexAppServerClient } from "./app-server-client.ts" +import type { GenerationArtifact, GenerationCall } from "./contracts.ts" + +const maximumImageBytes = 32 * 1024 * 1024 +const imageOrchestratorModel = "gpt-5.6-terra" + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined +} + +function safeStem(operationId: string) { + const value = operationId.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") + return (value || "image").slice(0, 80) +} + +function decodeResult(value: string) { + let mimeType = "image/png" + let encoded = value + if (value.startsWith("data:")) { + const match = value.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=\r\n]+)$/u) + if (!match) throw new Error("Local Codex returned an unsupported image result") + mimeType = match[1]! + encoded = match[2]! + } + const bytes = Buffer.from(encoded, "base64") + if (bytes.length === 0 || bytes.length > maximumImageBytes) { + throw new Error("Local Codex returned an invalid image result") + } + return { bytes, mimeType } +} + +function extensionFor(mimeType: string) { + return mimeType === "image/jpeg" ? "jpg" : mimeType === "image/webp" ? "webp" : "png" +} + +async function writeExclusive(directory: string, stem: string, mimeType: string, bytes: Uint8Array) { + await fs.mkdir(directory, { recursive: true }) + const extension = extensionFor(mimeType) + for (let index = 0; index < 1_000; index += 1) { + const name = `${stem}${index === 0 ? "" : `-${index + 1}`}.${extension}` + const destination = path.join(directory, name) + let handle: fs.FileHandle | undefined + try { + handle = await fs.open(destination, "wx", 0o600) + await handle.writeFile(bytes) + return { destination, name } + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error + } finally { + await handle?.close() + } + } + throw new Error("Unable to allocate a unique image output name") +} + +export class CodexImageGenerator { + constructor(private readonly client: CodexAppServerClient) {} + + async generate(call: GenerationCall, signal?: AbortSignal): Promise { + const capabilities = record(await this.client.request("modelProvider/capabilities/read", {}, { + signal, + timeoutMs: 10_000, + })) + if (capabilities?.imageGeneration !== true) { + throw new Error("The bound local Codex does not expose GPT Image 2 generation") + } + const started = record(await this.client.request("thread/start", { + approvalPolicy: "never", + baseInstructions: [ + "Generate exactly one image for the user request.", + "Use the built-in image_gen image-generation tool exactly once.", + "The image model is GPT Image 2. Do not use shell, files, web, MCP, or any other tool.", + "Do not ask follow-up questions. Preserve the supplied reference images when present.", + ].join("\n"), + cwd: process.cwd(), + developerInstructions: "Return the generated image without performing any other side effect.", + dynamicTools: [], + environments: [], + ephemeral: true, + experimentalRawEvents: true, + model: imageOrchestratorModel, + modelProvider: "openai", + sandbox: "read-only", + selectedCapabilityRoots: [], + serviceName: "convax_codex_image", + }, { signal, timeoutMs: 15_000 })) + const thread = record(started?.thread) + if (typeof thread?.id !== "string") throw new Error("Local Codex did not start an image thread") + const threadId = thread.id + let image: { result: string; savedPath?: string } | undefined + let activeTurnId: string | undefined + let resolveCompleted!: (status: string) => void + const completed = new Promise((resolve) => { resolveCompleted = resolve }) + const unsubscribe = this.client.onMessage((message) => { + const params = record(message.params) + if (params?.threadId !== threadId) return + if (message.method === "item/completed") { + const item = record(params.item) + if (item?.type === "imageGeneration" && item.status === "completed" && typeof item.result === "string") { + image = { + result: item.result, + ...(typeof item.savedPath === "string" ? { savedPath: item.savedPath } : {}), + } + } + } else if (message.method === "turn/completed") { + const turn = record(params.turn) + if (!activeTurnId || turn?.id === activeTurnId) resolveCompleted(typeof turn?.status === "string" ? turn.status : "failed") + } + }) + const onAbort = () => { + if (activeTurnId) { + void this.client.request("turn/interrupt", { threadId, turnId: activeTurnId }, { timeoutMs: 5_000 }).catch(() => undefined) + } + } + signal?.addEventListener("abort", onAbort, { once: true }) + try { + const input = [ + { text: call.prompt, text_elements: [], type: "text" }, + ...call.references.map((reference) => ({ path: reference.path, type: "localImage" })), + ] + const turnResult = record(await this.client.request("turn/start", { input, threadId }, { + signal, + timeoutMs: 15_000, + })) + const turn = record(turnResult?.turn) + if (typeof turn?.id !== "string") throw new Error("Local Codex did not start GPT Image 2 generation") + activeTurnId = turn.id + const status = await completed + if (status !== "completed" || !image) throw new Error("Local Codex GPT Image 2 generation failed") + let decoded: { bytes: Uint8Array; mimeType: string } + if (image.savedPath) { + const bytes = await fs.readFile(image.savedPath) + if (bytes.length === 0 || bytes.length > maximumImageBytes) throw new Error("Local Codex returned an invalid image file") + decoded = { bytes, mimeType: "image/png" } + } else { + decoded = decodeResult(image.result) + } + const output = await writeExclusive( + call.output_directory, + `codex-gpt-image-2-${safeStem(call.operation_id)}`, + decoded.mimeType, + decoded.bytes, + ) + return [{ mimeType: decoded.mimeType, name: output.name, path: output.name }] + } finally { + signal?.removeEventListener("abort", onAbort) + unsubscribe() + } + } +} diff --git a/packages/tools/codex-mcp/src/index.ts b/packages/tools/codex-mcp/src/index.ts new file mode 100644 index 0000000..eec3fff --- /dev/null +++ b/packages/tools/codex-mcp/src/index.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env bun + +import { McpServer } from "./mcp-server.ts" +import { CodexRuntime } from "./runtime.ts" + +const shutdownGracePeriodMs = 5_000 + +export function createServer(environment: Record = process.env) { + return new McpServer(new CodexRuntime({ environment })) +} + +async function run() { + if (process.argv.length > 2) throw new Error("Usage: convax-codex-mcp") + const server = createServer() + let shutdown: Promise | undefined + const stop = () => { + shutdown ??= server.shutdown(shutdownGracePeriodMs) + void shutdown.then( + (drained) => { + if (!drained) console.error("[codex] shutdown grace period expired") + process.exit(0) + }, + () => process.exit(1), + ) + } + process.once("SIGINT", stop) + process.once("SIGTERM", stop) + try { + await server.run() + } finally { + try { + await (shutdown ?? server.shutdown(shutdownGracePeriodMs)) + } finally { + process.removeListener("SIGINT", stop) + process.removeListener("SIGTERM", stop) + } + } +} + +if (import.meta.main) await run() diff --git a/packages/tools/codex-mcp/src/llm-gateway.ts b/packages/tools/codex-mcp/src/llm-gateway.ts new file mode 100644 index 0000000..644713b --- /dev/null +++ b/packages/tools/codex-mcp/src/llm-gateway.ts @@ -0,0 +1,198 @@ +import { randomBytes } from "node:crypto" +import { + completionId, + CodexChatRunner, + InvalidChatCompletionError, + parseChatCompletionRequest, + type ChatCompletionResult, +} from "./chat-completions.ts" +import { codexLlmModels, llmGatewaySchema, type LlmGatewayDescriptor } from "./contracts.ts" + +const maximumRequestBytes = 8 * 1024 * 1024 + +function errorResponse(status: number, message: string, type: string) { + return Response.json({ error: { message, type } }, { + headers: { "Cache-Control": "no-store" }, + status, + }) +} + +async function requestBody(request: Request) { + const declared = request.headers.get("content-length") + if (declared !== null) { + const size = Number(declared) + if (!Number.isSafeInteger(size) || size < 1 || size > maximumRequestBytes) { + throw new InvalidChatCompletionError("request body is invalid") + } + } + const bytes = new Uint8Array(await request.arrayBuffer()) + if (bytes.length === 0 || bytes.length > maximumRequestBytes) { + throw new InvalidChatCompletionError("request body is invalid") + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown + } catch { + throw new InvalidChatCompletionError("request body is invalid") + } +} + +function toolCalls(result: ChatCompletionResult, includeIndex = false) { + return result.toolCalls.map((call, index) => ({ + function: { arguments: call.arguments, name: call.name }, + id: call.id, + ...(includeIndex ? { index } : {}), + type: "function", + })) +} + +function nonStreamingResponse(id: string, model: string, created: number, result: ChatCompletionResult) { + return Response.json({ + choices: [{ + finish_reason: result.toolCalls.length > 0 ? "tool_calls" : "stop", + index: 0, + message: { + content: result.content || null, + role: "assistant", + ...(result.toolCalls.length === 0 ? {} : { tool_calls: toolCalls(result) }), + }, + }], + created, + id, + model, + object: "chat.completion", + ...(result.usage === undefined ? {} : { usage: result.usage }), + }, { headers: { "Cache-Control": "no-store" } }) +} + +function sse(value: unknown) { + return `data: ${JSON.stringify(value)}\n\n` +} + +function streamingResponse( + id: string, + model: string, + created: number, + runner: CodexChatRunner, + input: ReturnType, + signal: AbortSignal, +) { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + let emittedContent = false + controller.enqueue(encoder.encode(sse({ + choices: [{ delta: { role: "assistant" }, finish_reason: null, index: 0 }], + created, + id, + model, + object: "chat.completion.chunk", + }))) + void runner.complete(input, { + signal, + onDelta(delta) { + emittedContent = true + controller.enqueue(encoder.encode(sse({ + choices: [{ delta: { content: delta }, finish_reason: null, index: 0 }], + created, + id, + model, + object: "chat.completion.chunk", + }))) + }, + }).then((result) => { + const delta = { + ...(!emittedContent && result.content ? { content: result.content } : {}), + ...(result.toolCalls.length === 0 ? {} : { tool_calls: toolCalls(result, true) }), + } + controller.enqueue(encoder.encode(sse({ + choices: [{ delta, finish_reason: result.toolCalls.length > 0 ? "tool_calls" : "stop", index: 0 }], + created, + id, + model, + object: "chat.completion.chunk", + ...(input.streamIncludeUsage && result.usage ? { usage: result.usage } : {}), + }))) + controller.enqueue(encoder.encode("data: [DONE]\n\n")) + controller.close() + }, () => controller.error(new Error("Local Codex completion failed"))) + }, + }) + return new Response(stream, { + headers: { + "Cache-Control": "no-cache, no-store", + "Content-Type": "text/event-stream; charset=utf-8", + }, + }) +} + +export class CodexLlmGateway { + readonly #runner: CodexChatRunner + readonly #token = randomBytes(32).toString("base64url") + #server: Bun.Server | undefined + #start: Promise | undefined + + constructor(runner: CodexChatRunner) { + this.#runner = runner + } + + start() { + return this.#start ??= this.#listen() + } + + async #listen() { + const server = Bun.serve({ + fetch: (request) => this.#handle(request), + hostname: "127.0.0.1", + maxRequestBodySize: maximumRequestBytes, + port: 0, + }) + this.#server = server + return { + api_key: this.#token, + base_url: `http://127.0.0.1:${server.port}/v1`, + schema: llmGatewaySchema, + } + } + + async #handle(request: Request) { + try { + const url = new URL(request.url) + if (url.host !== `127.0.0.1:${this.#server?.port ?? 0}`) { + return errorResponse(403, "Invalid loopback host", "invalid_request_error") + } + if (request.headers.get("authorization") !== `Bearer ${this.#token}`) { + return errorResponse(401, "Invalid gateway credential", "authentication_error") + } + if (request.method === "GET" && url.pathname === "/v1/models" && !url.search && !url.hash) { + return Response.json({ + data: codexLlmModels.map((model) => ({ created: 0, id: model.id, object: "model", owned_by: "codex" })), + object: "list", + }, { headers: { "Cache-Control": "no-store" } }) + } + if (request.method !== "POST" || url.pathname !== "/v1/chat/completions" || url.search || url.hash) { + return errorResponse(404, "LLM endpoint was not found", "invalid_request_error") + } + if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) { + return errorResponse(415, "LLM request must be JSON", "invalid_request_error") + } + const input = parseChatCompletionRequest(await requestBody(request)) + const id = completionId() + const created = Math.floor(Date.now() / 1_000) + if (input.stream) return streamingResponse(id, input.model, created, this.#runner, input, request.signal) + return nonStreamingResponse(id, input.model, created, await this.#runner.complete(input, { + signal: request.signal, + })) + } catch (error) { + if (request.signal.aborted) return errorResponse(499, "LLM request was cancelled", "cancelled") + if (error instanceof InvalidChatCompletionError) { + return errorResponse(400, error.message, "invalid_request_error") + } + return errorResponse(502, "Local Codex completion failed", "api_error") + } + } + + close() { + this.#server?.stop(true) + this.#server = undefined + } +} diff --git a/packages/tools/codex-mcp/src/mcp-server.ts b/packages/tools/codex-mcp/src/mcp-server.ts new file mode 100644 index 0000000..bd33bb7 --- /dev/null +++ b/packages/tools/codex-mcp/src/mcp-server.ts @@ -0,0 +1,256 @@ +import { asRecord, codexImageToolId, parseGenerationCall, type JsonRpcRequest, type ToolResult } from "./contracts.ts" +import { CodexChatRunner } from "./chat-completions.ts" +import { CodexImageGenerator } from "./image-generator.ts" +import { CodexLlmGateway } from "./llm-gateway.ts" +import { CodexPluginService } from "./plugin-service.ts" +import type { CodexRuntime } from "./runtime.ts" + +const protocolVersion = "2025-03-26" +const maximumRequestBytes = 64 * 1024 * 1024 + +const emptyInputSchema = { additionalProperties: false, properties: {}, type: "object" } as const + +export const tools = [ + { + description: "Generate or edit one image through local Codex GPT Image 2.", + inputSchema: { + additionalProperties: false, + properties: { + operation_id: { maxLength: 256, minLength: 1, type: "string" }, + output: { const: "image", type: "string" }, + output_directory: { maxLength: 4_096, minLength: 1, type: "string" }, + prompt: { maxLength: 20_000, minLength: 1, type: "string" }, + references: { items: { type: "object" }, maxItems: 16, type: "array" }, + schema: { const: "convax.generation-call/1", type: "string" }, + }, + required: ["schema", "operation_id", "prompt", "output", "output_directory", "references"], + type: "object", + }, + name: codexImageToolId, + }, + { + description: "Report bounded local Codex binding, account, credit, usage, model, and image-capability status.", + inputSchema: emptyInputSchema, + name: "service.status", + }, + { + description: "Probe and bind the existing local Codex installation and account without changing Codex login state.", + inputSchema: emptyInputSchema, + name: "service.authorize", + }, + { + description: "Re-probe the existing local Codex installation and account without changing Codex login state.", + inputSchema: emptyInputSchema, + name: "service.reauthorize", + }, + { + description: "Start the authenticated loopback OpenAI-compatible gateway backed by local Codex.", + inputSchema: emptyInputSchema, + name: "llm.gateway.start", + }, +] as const + +const toolNames = new Set(tools.map((tool) => tool.name)) +const serviceToolNames = new Set(["service.status", "service.authorize", "service.reauthorize"]) + +function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const record = value as Record + return record.jsonrpc === "2.0" && typeof record.method === "string" +} + +export interface McpServerOptions { + send?: (value: unknown) => void +} + +export class McpServer { + readonly #handlers = new Set>() + readonly #inflight = new Map() + readonly #runtime: CodexRuntime + readonly #sendValue: (value: unknown) => void + readonly #service: CodexPluginService + #closed = false + #gateway: CodexLlmGateway | undefined + #reader: ReadableStreamDefaultReader | undefined + + constructor(runtime: CodexRuntime, options: McpServerOptions = {}) { + this.#runtime = runtime + this.#service = new CodexPluginService(runtime) + this.#sendValue = options.send ?? ((value) => { Bun.stdout.write(`${JSON.stringify(value)}\n`) }) + } + + async run(input: ReadableStream = Bun.stdin.stream()) { + if (this.#reader) throw new Error("MCP server is already running") + let buffer = "" + const decoder = new TextDecoder() + const reader = input.getReader() + this.#reader = reader + try { + while (!this.#closed) { + const { done, value: chunk } = await reader.read() + if (done || this.#closed) break + buffer += decoder.decode(chunk, { stream: true }) + if (Buffer.byteLength(buffer, "utf8") > maximumRequestBytes) { + throw new Error("MCP request exceeded the message size limit") + } + while (true) { + const newline = buffer.indexOf("\n") + if (newline < 0) break + const line = buffer.slice(0, newline).trim() + buffer = buffer.slice(newline + 1) + if (!line) continue + try { + this.#dispatch(JSON.parse(line) as unknown) + } catch { + this.#sendError(null, -32_700, "Parse error") + } + } + } + } finally { + this.close() + if (this.#reader === reader) this.#reader = undefined + reader.releaseLock() + } + } + + #dispatch(value: unknown) { + const handler = this.#handle(value) + this.#handlers.add(handler) + void handler.finally(() => this.#handlers.delete(handler)) + } + + async #handle(value: unknown) { + if (this.#closed) return + if (!isJsonRpcRequest(value)) { + this.#sendError(null, -32_600, "Invalid Request") + return + } + if (value.method === "notifications/initialized") return + if (value.method === "notifications/cancelled") { + const requestId = (value.params && typeof value.params === "object" && !Array.isArray(value.params) + ? value.params as Record + : {}).requestId + if (typeof requestId === "number" || typeof requestId === "string") { + this.#inflight.get(requestId)?.abort("Request was cancelled") + } + return + } + if (value.id === undefined || value.id === null) return + if (value.method === "initialize") { + const params = asRecord(value.params, "initialize params") + if (params.protocolVersion !== protocolVersion) { + this.#sendError(value.id, -32_602, "Unsupported MCP protocol version") + return + } + this.#sendResult(value.id, { + capabilities: { tools: {} }, + protocolVersion, + serverInfo: { name: "convax-codex-mcp", version: "0.1.1" }, + }) + return + } + if (value.method === "tools/list") { + this.#sendResult(value.id, { tools }) + return + } + if (value.method === "tools/call") { + await this.#callTool({ ...value, id: value.id }) + return + } + this.#sendError(value.id, -32_601, "Method not found") + } + + async #callTool(request: JsonRpcRequest & { id: number | string }) { + const controller = new AbortController() + this.#inflight.set(request.id, controller) + try { + const params = asRecord(request.params, "tools/call params") + if (typeof params.name !== "string" || !toolNames.has(params.name as typeof tools[number]["name"])) { + this.#sendError(request.id, -32_602, "Unknown tool") + return + } + if (serviceToolNames.has(params.name)) { + const input = asRecord(params.arguments ?? {}, "service tool arguments") + if (Object.keys(input).length !== 0) { + this.#sendError(request.id, -32_602, "This service tool does not accept arguments") + return + } + const structuredContent = params.name === "service.status" + ? await this.#service.status() + : params.name === "service.authorize" + ? await this.#service.authorize() + : await this.#service.reauthorize() + if (params.name !== "service.status") { + this.#gateway?.close() + this.#gateway = undefined + } + this.#sendResult(request.id, { + content: [{ text: "Local Codex binding inspected.", type: "text" }], + structuredContent, + } satisfies ToolResult) + return + } + if (params.name === "llm.gateway.start") { + const input = asRecord(params.arguments ?? {}, "LLM gateway arguments") + if (Object.keys(input).length !== 0) { + this.#sendError(request.id, -32_602, "The LLM gateway tool does not accept arguments") + return + } + const client = await this.#runtime.client() + this.#gateway ??= new CodexLlmGateway(new CodexChatRunner(client)) + this.#sendResult(request.id, { + content: [{ text: "Local Codex LLM gateway started.", type: "text" }], + structuredContent: await this.#gateway.start(), + } satisfies ToolResult) + return + } + const call = parseGenerationCall(params.arguments) + const artifacts = await new CodexImageGenerator(await this.#runtime.client()).generate(call, controller.signal) + this.#sendResult(request.id, { + content: [{ text: `Generated ${artifacts.length} image artifact${artifacts.length === 1 ? "" : "s"}.`, type: "text" }], + structuredContent: { artifacts }, + } satisfies ToolResult) + } catch { + const cancelled = controller.signal.aborted + console.error(cancelled ? "[codex] request cancelled" : "[codex] request failed") + this.#sendResult(request.id, { + content: [{ text: cancelled ? "Local Codex request was cancelled." : "Local Codex request failed.", type: "text" }], + isError: true, + } satisfies ToolResult) + } finally { + this.#inflight.delete(request.id) + } + } + + #sendResult(id: number | string, result: unknown) { + this.#sendValue({ id, jsonrpc: "2.0", result }) + } + + #sendError(id: number | string | null, code: number, message: string) { + this.#sendValue({ error: { code, message }, id, jsonrpc: "2.0" }) + } + + close() { + if (this.#closed) return + this.#closed = true + for (const controller of this.#inflight.values()) controller.abort("MCP server is closing") + this.#gateway?.close() + this.#runtime.close() + void this.#reader?.cancel().catch(() => undefined) + } + + async shutdown(gracePeriodMs: number) { + if (!Number.isFinite(gracePeriodMs) || gracePeriodMs <= 0) throw new Error("MCP shutdown grace period must be positive") + this.close() + if (this.#handlers.size === 0) return true + let timer: ReturnType | undefined + try { + return await Promise.race([ + Promise.allSettled([...this.#handlers]).then(() => true), + new Promise((resolve) => { timer = setTimeout(() => resolve(false), gracePeriodMs) }), + ]) + } finally { + if (timer) clearTimeout(timer) + } + } +} diff --git a/packages/tools/codex-mcp/src/plugin-service.ts b/packages/tools/codex-mcp/src/plugin-service.ts new file mode 100644 index 0000000..ef72fa0 --- /dev/null +++ b/packages/tools/codex-mcp/src/plugin-service.ts @@ -0,0 +1,132 @@ +import type { CodexAppServerClient } from "./app-server-client.ts" +import { + codexLlmModelIds, + pluginServiceStatusSchema, + type PluginServiceStatus, +} from "./contracts.ts" +import type { CodexRuntime } from "./runtime.ts" + +const unavailable = { availability: "unavailable" } as const +const metadataTimeoutMs = 10_000 + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined +} + +function disconnected(verification: PluginServiceStatus["credential"]["verification"] = "unknown"): PluginServiceStatus { + return { + account: unavailable, + credential: { configured: false, verification }, + credits: unavailable, + schema: pluginServiceStatusSchema, + state: "disconnected", + usage: unavailable, + } +} + +function displayAccount(account: Record) { + if (account.type === "chatgpt" && typeof account.email === "string" && account.email.length <= 320) { + return { availability: "available" as const, displayName: account.email } + } + if (account.type === "chatgpt" && typeof account.planType === "string") { + return { + availability: "available" as const, + displayName: `ChatGPT ${account.planType}`.slice(0, 120), + } + } + if (account.type === "apiKey") { + return { availability: "available" as const, displayName: "OpenAI API key" } + } + return unavailable +} + +function boundedPercentage(value: unknown) { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100 + ? value + : undefined +} + +function usageFor(rateLimits: Record | undefined): PluginServiceStatus["usage"] { + const primary = record(rateLimits?.primary) + const consumed = boundedPercentage(primary?.usedPercent) + if (consumed === undefined) return unavailable + const duration = primary?.windowDurationMins + const resetsAt = primary?.resetsAt + const details = [ + typeof duration === "number" && Number.isSafeInteger(duration) && duration > 0 + ? `${duration} minute window` + : undefined, + typeof resetsAt === "number" && Number.isSafeInteger(resetsAt) && resetsAt > 0 + ? `resets ${new Date(resetsAt * 1_000).toISOString()}` + : undefined, + ].filter(Boolean).join(", ") + return { + availability: "available", + consumed, + ...(details ? { period: details.slice(0, 120) } : {}), + unit: "% of Codex quota", + } +} + +function creditsFor(rateLimits: Record | undefined): PluginServiceStatus["credits"] { + const credits = record(rateLimits?.credits) + if (!credits || credits.unlimited === true || credits.hasCredits !== true || typeof credits.balance !== "string") { + return unavailable + } + const remaining = Number(credits.balance) + return Number.isFinite(remaining) && remaining >= 0 + ? { availability: "available", remaining, unit: "Codex credits" } + : unavailable +} + +async function inspect(client: CodexAppServerClient): Promise { + const [accountResult, rateResult, modelResult, capabilityResult] = await Promise.all([ + client.request("account/read", { refreshToken: false }, { timeoutMs: metadataTimeoutMs }), + client.request("account/rateLimits/read", undefined, { timeoutMs: metadataTimeoutMs }).catch(() => undefined), + client.request("model/list", { includeHidden: true, limit: 100 }, { timeoutMs: metadataTimeoutMs }), + client.request("modelProvider/capabilities/read", {}, { timeoutMs: metadataTimeoutMs }), + ]) + const account = record(record(accountResult)?.account) + if (!account) return disconnected("verified") + const models = Array.isArray(record(modelResult)?.data) ? record(modelResult)?.data as unknown[] : [] + const availableIds = new Set(models.map((model) => record(model)?.id).filter((id): id is string => typeof id === "string")) + const capabilities = record(capabilityResult) + const catalogReady = [...codexLlmModelIds].every((id) => availableIds.has(id)) + const imageReady = capabilities?.imageGeneration === true + const rateLimits = record(record(rateResult)?.rateLimits) + return { + account: displayAccount(account), + credential: { configured: true, verification: catalogReady && imageReady ? "verified" : "unverified" }, + credits: creditsFor(rateLimits), + schema: pluginServiceStatusSchema, + state: catalogReady && imageReady ? "connected" : "attention", + usage: usageFor(rateLimits), + } +} + +export class CodexPluginService { + constructor(private readonly runtime: CodexRuntime) {} + + async status() { + try { + return await inspect(await this.runtime.client()) + } catch { + return disconnected("failed") + } + } + + async authorize() { + try { + await this.runtime.rebind() + } catch { + return disconnected("failed") + } + return await this.status() + } + + async reauthorize() { + return await this.authorize() + } +} diff --git a/packages/tools/codex-mcp/src/runtime.ts b/packages/tools/codex-mcp/src/runtime.ts new file mode 100644 index 0000000..0fa4545 --- /dev/null +++ b/packages/tools/codex-mcp/src/runtime.ts @@ -0,0 +1,56 @@ +import { CodexAppServerClient } from "./app-server-client.ts" +import { resolveCodexExecutable, type CodexLocatorOptions } from "./codex-locator.ts" + +export interface CodexRuntimeOptions extends CodexLocatorOptions { + createClient?: (executable: string) => CodexAppServerClient +} + +export class CodexRuntime { + readonly #createClient: NonNullable + readonly #locatorOptions: CodexLocatorOptions + #client: CodexAppServerClient | undefined + #starting: Promise | undefined + + constructor(options: CodexRuntimeOptions = {}) { + const environment = options.environment ?? process.env + this.#createClient = options.createClient ?? ((executable) => new CodexAppServerClient(executable, { + environment: { ...environment }, + })) + this.#locatorOptions = { + environment, + ...(options.platform === undefined ? {} : { platform: options.platform }), + ...(options.probeVersion === undefined ? {} : { probeVersion: options.probeVersion }), + } + } + + async client() { + if (this.#client) return this.#client + return await (this.#starting ??= this.#start()) + } + + async #start() { + let client: CodexAppServerClient | undefined + try { + client = this.#createClient(await resolveCodexExecutable(this.#locatorOptions)) + await client.start() + this.#client = client + return client + } catch (error) { + client?.close() + throw error + } finally { + this.#starting = undefined + } + } + + async rebind() { + this.#client?.close() + this.#client = undefined + return await this.client() + } + + close() { + this.#client?.close() + this.#client = undefined + } +} diff --git a/packages/tools/codex-mcp/test/chat-completions.test.ts b/packages/tools/codex-mcp/test/chat-completions.test.ts new file mode 100644 index 0000000..b7bb873 --- /dev/null +++ b/packages/tools/codex-mcp/test/chat-completions.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test" +import type { + AppServerMessage, + AppServerRequestContext, + AppServerRequestHandler, + CodexAppServerClient, +} from "../src/app-server-client.ts" +import { CodexChatRunner, parseChatCompletionRequest } from "../src/chat-completions.ts" + +class FakeClient { + readonly calls: Array<{ method: string; params: unknown }> = [] + readonly listeners = new Set<(message: AppServerMessage) => void>() + readonly handlers = new Set() + toolCall = false + + onMessage(listener: (message: AppServerMessage) => void) { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + onRequest(handler: AppServerRequestHandler) { + this.handlers.add(handler) + return () => this.handlers.delete(handler) + } + + async request(method: string, params: unknown) { + this.calls.push({ method, params }) + if (method === "model/list") return { data: [{ id: "gpt-5.6-sol" }] } + if (method === "thread/start") return { thread: { id: "thread-1" } } + if (method === "thread/inject_items") return {} + if (method === "turn/interrupt") return {} + if (method === "turn/start") { + queueMicrotask(() => { void this.completeTurn() }) + return { turn: { id: "turn-1" } } + } + throw new Error(`unexpected ${method}`) + } + + async completeTurn() { + if (this.toolCall) { + const request: AppServerRequestContext = { + id: 9, + method: "item/tool/call", + params: { arguments: { id: "ABC" }, callId: "call-1", namespace: null, threadId: "thread-1", tool: "lookup", turnId: "turn-1" }, + } + for (const handler of this.handlers) if ((await handler(request)).handled) break + this.emit("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted" } }) + return + } + this.emit("item/agentMessage/delta", { delta: "hello", threadId: "thread-1", turnId: "turn-1" }) + this.emit("rawResponse/completed", { + threadId: "thread-1", + turnId: "turn-1", + usage: { inputTokens: 2, outputTokens: 1, totalTokens: 3 }, + }) + this.emit("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } }) + } + + emit(method: string, params: unknown) { + for (const listener of this.listeners) listener({ method, params }) + } +} + +describe("Codex chat completion bridge", () => { + test("validates model ids, messages, and dynamic tools", () => { + expect(parseChatCompletionRequest({ + messages: [{ content: "hello", role: "user" }], + model: "gpt-5.6-sol", + tools: [{ function: { name: "lookup", parameters: { type: "object" } }, type: "function" }], + })).toMatchObject({ model: "gpt-5.6-sol", tools: [{ name: "lookup" }] }) + expect(() => parseChatCompletionRequest({ messages: [{ content: "x", role: "user" }], model: "other" })).toThrow("not supported") + }) + + test("runs a read-only ephemeral Codex turn and preserves usage", async () => { + const client = new FakeClient() + const deltas: string[] = [] + const result = await new CodexChatRunner(client as unknown as CodexAppServerClient).complete( + parseChatCompletionRequest({ messages: [{ content: "hello", role: "user" }], model: "gpt-5.6-sol" }), + { onDelta: (delta) => deltas.push(delta) }, + ) + expect(result).toEqual({ + content: "hello", + toolCalls: [], + usage: { completion_tokens: 1, prompt_tokens: 2, total_tokens: 3 }, + }) + expect(deltas).toEqual(["hello"]) + expect(client.calls.find((call) => call.method === "thread/start")?.params).toMatchObject({ + approvalPolicy: "never", + ephemeral: true, + model: "gpt-5.6-sol", + sandbox: "read-only", + }) + }) + + test("returns dynamic tool calls without executing them", async () => { + const client = new FakeClient() + client.toolCall = true + const request = parseChatCompletionRequest({ + messages: [{ content: "find ABC", role: "user" }], + model: "gpt-5.6-sol", + tools: [{ function: { name: "lookup", parameters: { type: "object" } }, type: "function" }], + }) + await expect(new CodexChatRunner(client as unknown as CodexAppServerClient).complete(request)).resolves.toEqual({ + content: "", + toolCalls: [{ arguments: '{"id":"ABC"}', id: "call-1", name: "lookup" }], + }) + expect(client.calls.some((call) => call.method === "turn/interrupt")).toBeTrue() + }) +}) diff --git a/packages/tools/codex-mcp/test/codex-locator.test.ts b/packages/tools/codex-mcp/test/codex-locator.test.ts new file mode 100644 index 0000000..709dd2e --- /dev/null +++ b/packages/tools/codex-mcp/test/codex-locator.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { resolveCodexExecutable } from "../src/codex-locator.ts" + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { force: true, recursive: true }))) +}) + +describe("local Codex locator", () => { + test("fails closed when no compatible executable is present", async () => { + await expect(resolveCodexExecutable({ + environment: { HOME: "/nonexistent", PATH: "/nonexistent" }, + platform: "linux", + probeVersion: async () => false, + })).rejects.toThrow("compatible local Codex") + }) + + test("accepts a validated executable from an absolute host PATH", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "convax-codex-locator-test-")) + temporaryDirectories.push(directory) + const executable = path.join(directory, "codex") + await fs.writeFile(executable, "test") + await fs.chmod(executable, 0o700) + const canonicalExecutable = await fs.realpath(executable) + const result = await resolveCodexExecutable({ + environment: { HOME: "/nonexistent", PATH: directory }, + platform: "linux", + probeVersion: async (candidate) => candidate === canonicalExecutable, + }) + expect(result).toBe(canonicalExecutable) + }) +}) diff --git a/packages/tools/codex-mcp/test/contracts.test.ts b/packages/tools/codex-mcp/test/contracts.test.ts new file mode 100644 index 0000000..69376a3 --- /dev/null +++ b/packages/tools/codex-mcp/test/contracts.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import { codexLlmModels, parseGenerationCall } from "../src/contracts.ts" + +function call(overrides: Record = {}) { + return { + operation_id: "operation-1", + output: "image", + output_directory: path.resolve("/tmp/convax-codex-output"), + prompt: "Create a quiet landscape", + references: [], + schema: "convax.generation-call/1", + ...overrides, + } +} + +describe("Codex companion contracts", () => { + test("declares the exact current GPT-5.6 and GPT-5.5 catalog", () => { + expect(codexLlmModels.map((model) => model.id)).toEqual([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + ]) + }) + + test("accepts bounded image calls and staged image references", () => { + expect(parseGenerationCall(call({ + references: [{ + kind: "file", + mime_type: "image/png", + name: "reference.png", + node_id: "node-1", + path: path.resolve("/tmp/reference.png"), + role: "reference_image", + }], + }))).toMatchObject({ output: "image", references: [{ role: "reference_image" }] }) + }) + + test("rejects non-image references, relative paths, and unsupported fields", () => { + expect(() => parseGenerationCall(call({ output_directory: "relative" }))).toThrow("absolute") + expect(() => parseGenerationCall(call({ references: [{ + kind: "file", + mime_type: "video/mp4", + name: "video.mp4", + node_id: "node-1", + path: path.resolve("/tmp/video.mp4"), + role: "reference_image", + }] }))).toThrow("invalid") + expect(() => parseGenerationCall(call({ provider: "private" }))).toThrow("unsupported") + }) +}) diff --git a/packages/tools/codex-mcp/test/image-generator.test.ts b/packages/tools/codex-mcp/test/image-generator.test.ts new file mode 100644 index 0000000..7c1fb3d --- /dev/null +++ b/packages/tools/codex-mcp/test/image-generator.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import type { AppServerMessage, CodexAppServerClient } from "../src/app-server-client.ts" +import type { GenerationCall } from "../src/contracts.ts" +import { CodexImageGenerator } from "../src/image-generator.ts" + +const temporaryDirectories: string[] = [] +const onePixelPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { force: true, recursive: true }))) +}) + +class FakeClient { + readonly listeners = new Set<(message: AppServerMessage) => void>() + + onMessage(listener: (message: AppServerMessage) => void) { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + async request(method: string) { + if (method === "modelProvider/capabilities/read") return { imageGeneration: true } + if (method === "thread/start") return { thread: { id: "thread-1" } } + if (method === "turn/start") { + queueMicrotask(() => { + this.emit("item/completed", { + item: { result: onePixelPng, status: "completed", type: "imageGeneration" }, + threadId: "thread-1", + turnId: "turn-1", + }) + this.emit("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } }) + }) + return { turn: { id: "turn-1" } } + } + if (method === "turn/interrupt") return {} + throw new Error(`unexpected ${method}`) + } + + emit(method: string, params: unknown) { + for (const listener of this.listeners) listener({ method, params }) + } +} + +describe("Codex GPT Image 2 generation", () => { + test("materializes the completed Codex image beneath output_directory", async () => { + const output = await fs.mkdtemp(path.join(os.tmpdir(), "convax-codex-image-test-")) + temporaryDirectories.push(output) + const call: GenerationCall = { + operation_id: "operation-1", + output: "image", + output_directory: output, + prompt: "one quiet pixel", + references: [], + schema: "convax.generation-call/1", + } + const artifacts = await new CodexImageGenerator(new FakeClient() as unknown as CodexAppServerClient).generate(call) + expect(artifacts).toEqual([{ + mimeType: "image/png", + name: "codex-gpt-image-2-operation-1.png", + path: "codex-gpt-image-2-operation-1.png", + }]) + expect((await fs.stat(path.join(output, artifacts[0]!.path))).size).toBeGreaterThan(0) + }) +}) diff --git a/packages/tools/codex-mcp/test/llm-gateway.test.ts b/packages/tools/codex-mcp/test/llm-gateway.test.ts new file mode 100644 index 0000000..862233d --- /dev/null +++ b/packages/tools/codex-mcp/test/llm-gateway.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, test } from "bun:test" +import type { CodexChatRunner } from "../src/chat-completions.ts" +import { CodexLlmGateway } from "../src/llm-gateway.ts" + +const gateways: CodexLlmGateway[] = [] + +afterEach(() => { + for (const gateway of gateways.splice(0)) gateway.close() +}) + +function gateway() { + const runner = { + async complete(_request: unknown, options: { onDelta?: (delta: string) => void } = {}) { + options.onDelta?.("hello") + return { + content: "hello", + toolCalls: [], + usage: { completion_tokens: 1, prompt_tokens: 2, total_tokens: 3 }, + } + }, + } as unknown as CodexChatRunner + const value = new CodexLlmGateway(runner) + gateways.push(value) + return value +} + +describe("Codex LLM gateway", () => { + test("requires the random bearer token and exposes only declared models", async () => { + const descriptor = await gateway().start() + const denied = await fetch(`${descriptor.base_url}/models`) + expect(denied.status).toBe(401) + const response = await fetch(`${descriptor.base_url}/models`, { + headers: { Authorization: `Bearer ${descriptor.api_key}` }, + }) + expect(response.status).toBe(200) + expect((await response.json() as { data: Array<{ id: string }> }).data.map((model) => model.id)).toEqual([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + ]) + }) + + test("serves non-streaming OpenAI-compatible chat completions", async () => { + const descriptor = await gateway().start() + const response = await fetch(`${descriptor.base_url}/chat/completions`, { + body: JSON.stringify({ messages: [{ content: "hi", role: "user" }], model: "gpt-5.6-sol" }), + headers: { + Authorization: `Bearer ${descriptor.api_key}`, + "Content-Type": "application/json", + }, + method: "POST", + }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + choices: [{ finish_reason: "stop", message: { content: "hello", role: "assistant" } }], + model: "gpt-5.6-sol", + object: "chat.completion", + usage: { completion_tokens: 1, prompt_tokens: 2, total_tokens: 3 }, + }) + }) + + test("serves SSE and rejects undeclared models before invoking Codex", async () => { + const descriptor = await gateway().start() + const headers = { + Authorization: `Bearer ${descriptor.api_key}`, + "Content-Type": "application/json", + } + const streamed = await fetch(`${descriptor.base_url}/chat/completions`, { + body: JSON.stringify({ + messages: [{ content: "hi", role: "user" }], + model: "gpt-5.5", + stream: true, + stream_options: { include_usage: true }, + }), + headers, + method: "POST", + }) + expect(streamed.headers.get("content-type")).toContain("text/event-stream") + const body = await streamed.text() + expect(body).toContain('"content":"hello"') + expect(body).toContain('"total_tokens":3') + expect(body).toContain("data: [DONE]") + + const rejected = await fetch(`${descriptor.base_url}/chat/completions`, { + body: JSON.stringify({ messages: [{ content: "hi", role: "user" }], model: "gpt-4" }), + headers, + method: "POST", + }) + expect(rejected.status).toBe(400) + }) +}) diff --git a/packages/tools/codex-mcp/test/model-catalog.test.ts b/packages/tools/codex-mcp/test/model-catalog.test.ts new file mode 100644 index 0000000..7f8873e --- /dev/null +++ b/packages/tools/codex-mcp/test/model-catalog.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { codexLlmModels, codexImageToolId } from "../src/contracts.ts" +import { tools } from "../src/mcp-server.ts" + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..") + +describe("Codex Plugin catalog", () => { + test("keeps manifest models and MCP tools aligned", async () => { + const manifest = JSON.parse(await fs.readFile( + path.join(root, "plugins/codex-service/package/manifest.json"), + "utf8", + )) as { + contributes: { + generation: { models: Array<{ name: string; tool: string }>; tools: Array<{ id: string }> } + llm: { models: Array<{ id: string; name: string }>; provider: { id: string; name: string } } + service: { actions: string[] } + } + schema: string + } + expect(manifest.schema).toBe("convax.plugin/5") + expect(manifest.contributes.llm).toEqual({ + models: codexLlmModels.map((model) => ({ ...model })), + provider: { id: "codex", name: "Codex" }, + }) + expect(manifest.contributes.generation.models).toEqual([{ name: "GPT Image 2", tool: codexImageToolId }]) + expect(manifest.contributes.generation.tools.map((tool) => tool.id)).toEqual([codexImageToolId]) + expect(manifest.contributes.service.actions).toEqual(["authorize", "reauthorize"]) + expect(tools.map((tool) => tool.name)).toEqual([ + codexImageToolId, + "service.status", + "service.authorize", + "service.reauthorize", + "llm.gateway.start", + ]) + }) +}) diff --git a/packages/tools/codex-mcp/test/plugin-service.test.ts b/packages/tools/codex-mcp/test/plugin-service.test.ts new file mode 100644 index 0000000..c5d42b5 --- /dev/null +++ b/packages/tools/codex-mcp/test/plugin-service.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test" +import type { CodexAppServerClient } from "../src/app-server-client.ts" +import { CodexPluginService } from "../src/plugin-service.ts" +import type { CodexRuntime } from "../src/runtime.ts" + +function client(responses: Record) { + return { + async request(method: string) { + if (!(method in responses)) throw new Error("unavailable") + return responses[method] + }, + } as unknown as CodexAppServerClient +} + +function runtime(bound: CodexAppServerClient, rebind = bound) { + return { + async client() { return bound }, + async rebind() { return rebind }, + } as unknown as CodexRuntime +} + +function connectedResponses() { + return { + "account/read": { account: { email: "person@example.com", planType: "pro", type: "chatgpt" }, requiresOpenaiAuth: true }, + "account/rateLimits/read": { + rateLimits: { + credits: { balance: "12.5", hasCredits: true, unlimited: false }, + primary: { resetsAt: 1_800_000_000, usedPercent: 37, windowDurationMins: 10_080 }, + }, + }, + "model/list": { data: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"].map((id) => ({ id })) }, + "modelProvider/capabilities/read": { imageGeneration: true }, + } +} + +describe("Codex Plugin service", () => { + test("projects bounded account, credit, and quota metadata", async () => { + const status = await new CodexPluginService(runtime(client(connectedResponses()))).status() + expect(status).toEqual({ + account: { availability: "available", displayName: "person@example.com" }, + credential: { configured: true, verification: "verified" }, + credits: { availability: "available", remaining: 12.5, unit: "Codex credits" }, + schema: "convax.plugin-service-status/1", + state: "connected", + usage: { + availability: "available", + consumed: 37, + period: expect.stringContaining("10080 minute window"), + unit: "% of Codex quota", + }, + }) + }) + + test("marks a logged-out or incomplete local binding honestly", async () => { + const loggedOut = client({ + ...connectedResponses(), + "account/read": { account: null, requiresOpenaiAuth: true }, + }) + await expect(new CodexPluginService(runtime(loggedOut)).status()).resolves.toMatchObject({ + credential: { configured: false, verification: "verified" }, + state: "disconnected", + }) + + const missingImage = client({ + ...connectedResponses(), + "modelProvider/capabilities/read": { imageGeneration: false }, + }) + await expect(new CodexPluginService(runtime(missingImage)).status()).resolves.toMatchObject({ + credential: { configured: true, verification: "unverified" }, + state: "attention", + }) + }) + + test("authorize only re-probes the local binding", async () => { + let rebound = 0 + const bound = client(connectedResponses()) + const fakeRuntime = { + async client() { return bound }, + async rebind() { rebound += 1; return bound }, + } as unknown as CodexRuntime + await expect(new CodexPluginService(fakeRuntime).authorize()).resolves.toMatchObject({ state: "connected" }) + expect(rebound).toBe(1) + }) +}) diff --git a/packages/tools/codex-mcp/tsconfig.json b/packages/tools/codex-mcp/tsconfig.json new file mode 100644 index 0000000..5b98b97 --- /dev/null +++ b/packages/tools/codex-mcp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "allowJs": false, + "exactOptionalPropertyTypes": true, + "lib": ["ES2023", "DOM"], + "module": "Preserve", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "strict": true, + "target": "ES2023", + "types": ["bun"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/registry/config.json b/registry/config.json index e70fea7..4ee78d7 100644 --- a/registry/config.json +++ b/registry/config.json @@ -1,4 +1,4 @@ { - "sequence": 25, + "sequence": 26, "yanked": [] } diff --git a/schemas/convax-package-v1.schema.json b/schemas/convax-package-v1.schema.json index c3fd44c..3795cc8 100644 --- a/schemas/convax-package-v1.schema.json +++ b/schemas/convax-package-v1.schema.json @@ -52,6 +52,15 @@ "pluginSchema": { "const": "convax.plugin/4" }, "pluginHost": { "const": "convax.plugin-host/4" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["pluginSchema", "pluginHost"], + "properties": { + "pluginSchema": { "const": "convax.plugin/5" }, + "pluginHost": { "const": "convax.plugin-capability/1" } + } } ] }, @@ -83,6 +92,12 @@ "pluginSchema": { "const": "convax.plugin/4" }, "pluginHost": { "const": "convax.plugin-host/4" } } + }, + { + "properties": { + "pluginSchema": { "const": "convax.plugin/5" }, + "pluginHost": { "const": "convax.plugin-capability/1" } + } } ] } diff --git a/schemas/convax-plugin-manifest-v5.schema.json b/schemas/convax-plugin-manifest-v5.schema.json new file mode 100644 index 0000000..9070c6d --- /dev/null +++ b/schemas/convax-plugin-manifest-v5.schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://microvoid.github.io/convax-plugins/schemas/convax-plugin-manifest-v5.schema.json", + "title": "Convax Plugin v5 manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema", "id", "name", "description", "version", "contributes"], + "properties": { + "schema": { "const": "convax.plugin/5" }, + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 80 }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 }, + "description": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "version": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/semver" }, + "entry": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/path" }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "maxItems": 7, + "items": { + "enum": [ + "canvas.connectedImages.read", + "canvas.node.read", + "canvas.node.write", + "project.files.read", + "agent.prompt", + "generation.execute", + "ui.fullscreen" + ] + } + }, + "contributes": { + "type": "object", + "additionalProperties": false, + "properties": { + "agent": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/agent" }, + "canvas": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/canvas" }, + "generation": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/generation" }, + "llm": { "$ref": "#/$defs/llm" }, + "service": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/service" }, + "skills": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/skills" } + } + }, + "runtime": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/runtime" } + }, + "allOf": [ + { + "if": { "required": ["entry"] }, + "then": { + "properties": { + "contributes": { + "required": ["canvas"], + "properties": { "canvas": { "required": ["renderer"] } } + } + } + } + }, + { + "if": { + "properties": { + "contributes": { + "required": ["canvas"], + "properties": { "canvas": { "required": ["renderer"] } } + } + }, + "required": ["contributes"] + }, + "then": { "required": ["entry"] } + }, + { + "if": { "required": ["runtime"] }, + "then": { + "properties": { + "contributes": { + "anyOf": [{ "required": ["generation"] }, { "required": ["llm"] }, { "required": ["service"] }] + } + } + } + }, + { + "if": { + "properties": { "contributes": { "anyOf": [{ "required": ["generation"] }, { "required": ["llm"] }, { "required": ["service"] }] } }, + "required": ["contributes"] + }, + "then": { "required": ["runtime"] } + }, + { + "if": { + "properties": { "contributes": { "required": ["agent"] } }, + "required": ["contributes"] + }, + "then": { "properties": { "contributes": { "required": ["generation"] } } } + }, + { + "if": { + "properties": { + "contributes": { + "required": ["canvas"], + "properties": { "canvas": { "required": ["selectionActions"] } } + } + }, + "required": ["contributes"] + }, + "then": { "properties": { "contributes": { "required": ["generation"] } } } + }, + { + "if": { + "properties": { "capabilities": { "contains": { "const": "generation.execute" } } }, + "required": ["capabilities"] + }, + "then": { + "required": ["entry"], + "properties": { + "contributes": { + "required": ["canvas"], + "properties": { "canvas": { "required": ["renderer"] } } + } + } + } + } + ], + "anyOf": [ + { "required": ["runtime"] }, + { "required": ["entry"] }, + { + "properties": { "capabilities": { "contains": { "const": "generation.execute" } } }, + "required": ["capabilities"] + } + ], + "$defs": { + "llm": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "models"], + "properties": { + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 80 }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 } + } + }, + "models": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", "maxLength": 128 }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 } + } + } + } + } + } + } +} diff --git a/schemas/convax-registry-v1.schema.json b/schemas/convax-registry-v1.schema.json index ef65657..77ee51e 100644 --- a/schemas/convax-registry-v1.schema.json +++ b/schemas/convax-registry-v1.schema.json @@ -80,6 +80,15 @@ "pluginSchema": { "const": "convax.plugin/4" }, "pluginHost": { "const": "convax.plugin-host/4" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["pluginSchema", "pluginHost"], + "properties": { + "pluginSchema": { "const": "convax.plugin/5" }, + "pluginHost": { "const": "convax.plugin-capability/1" } + } } ] }, @@ -90,7 +99,8 @@ { "$ref": "./convax-plugin-manifest-v1.schema.json" }, { "$ref": "./convax-plugin-manifest-v2.schema.json" }, { "$ref": "./convax-plugin-manifest-v3.schema.json" }, - { "$ref": "./convax-plugin-manifest-v4.schema.json" } + { "$ref": "./convax-plugin-manifest-v4.schema.json" }, + { "$ref": "./convax-plugin-manifest-v5.schema.json" } ] }, "companions": { "$ref": "#/$defs/companions" } @@ -144,6 +154,18 @@ }, "then": { "properties": { "manifest": { "$ref": "./convax-plugin-manifest-v4.schema.json" } } } }, + { + "if": { + "properties": { + "compatibility": { + "properties": { "pluginSchema": { "const": "convax.plugin/5" } }, + "required": ["pluginSchema"] + } + }, + "required": ["compatibility"] + }, + "then": { "properties": { "manifest": { "$ref": "./convax-plugin-manifest-v5.schema.json" } } } + }, { "if": { "required": ["companions"] }, "then": { @@ -167,6 +189,12 @@ "pluginSchema": { "const": "convax.plugin/4" }, "pluginHost": { "const": "convax.plugin-host/4" } } + }, + { + "properties": { + "pluginSchema": { "const": "convax.plugin/5" }, + "pluginHost": { "const": "convax.plugin-capability/1" } + } } ] }, @@ -189,6 +217,12 @@ { "$ref": "./convax-plugin-manifest-v4.schema.json" }, { "required": ["runtime"] } ] + }, + { + "allOf": [ + { "$ref": "./convax-plugin-manifest-v5.schema.json" }, + { "required": ["runtime"] } + ] } ] } diff --git a/tooling/lib.mjs b/tooling/lib.mjs index 688bd5a..8f8512d 100644 --- a/tooling/lib.mjs +++ b/tooling/lib.mjs @@ -143,8 +143,9 @@ function parseCompatibility(value, kind, label) { const v2 = value.pluginSchema === "convax.plugin/2" && value.pluginHost === "convax.plugin-host/2" const v3 = value.pluginSchema === "convax.plugin/3" && value.pluginHost === "convax.plugin-host/3" const v4 = value.pluginSchema === "convax.plugin/4" && value.pluginHost === "convax.plugin-host/4" - if (!v1 && !v2 && !v3 && !v4) { - error(label, "must pair matching convax.plugin and convax.plugin-host major versions 1, 2, 3, or 4") + const v5 = value.pluginSchema === "convax.plugin/5" && value.pluginHost === "convax.plugin-capability/1" + if (!v1 && !v2 && !v3 && !v4 && !v5) { + error(label, "must pair matching convax.plugin and convax.plugin-host versions, or convax.plugin/5 with convax.plugin-capability/1") } return { pluginSchema: value.pluginSchema, pluginHost: value.pluginHost } } @@ -226,8 +227,9 @@ export function parseSourceMetadata(value, label = "convax-package.json") { const compatibility = parseCompatibility(value.compatibility, kind, `${label} compatibility`) const companions = parseSourceCompanions(value.companions, `${label} companions`) if (companions && compatibility.pluginSchema !== "convax.plugin/2" && - compatibility.pluginSchema !== "convax.plugin/3" && compatibility.pluginSchema !== "convax.plugin/4") { - error(label, "companions require convax.plugin/2, convax.plugin/3, or convax.plugin/4 compatibility") + compatibility.pluginSchema !== "convax.plugin/3" && compatibility.pluginSchema !== "convax.plugin/4" && + compatibility.pluginSchema !== "convax.plugin/5") { + error(label, "companions require convax.plugin/2 through convax.plugin/5 compatibility") } return { schema: "convax.package/1", @@ -608,12 +610,32 @@ function parseOwnedSkillsV4(value, label) { return skills } -function parsePluginManifestV4(value, label) { +function parseLlmV5(value, label) { + exactKeys(value, ["models", "provider"], ["models", "provider"], label) + exactKeys(value.provider, ["id", "name"], ["id", "name"], `${label} provider`) + const providerId = parseId(value.provider.id, `${label} provider id`) + if (!Array.isArray(value.models) || value.models.length < 1 || value.models.length > 32) { + error(label, "models must be a non-empty array with at most 32 items") + } + const models = value.models.map((item, index) => { + const itemLabel = `${label} model ${index}` + exactKeys(item, ["id", "name"], ["id", "name"], itemLabel) + const id = cleanString(item.id, `${itemLabel} id`, 128) + if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(id)) error(itemLabel, "id is invalid") + return { id, name: cleanString(item.name, `${itemLabel} name`, 120) } + }) + if (new Set(models.map((model) => model.id)).size !== models.length) error(label, "models contain duplicate ids") + return { models, provider: { id: providerId, name: cleanString(value.provider.name, `${label} provider name`, 120) } } +} + +function parsePluginManifestV4(value, label, schema = "convax.plugin/4") { const required = ["contributes", "description", "id", "name", "schema", "version"] exactKeys(value, ["capabilities", "contributes", "description", "entry", "id", "name", "runtime", "schema", "version"], required, label) - exactKeys(value.contributes, ["agent", "canvas", "generation", "service", "skills"], [], `${label} contributes`) + exactKeys(value.contributes, + ["agent", "canvas", "generation", ...(schema === "convax.plugin/5" ? ["llm"] : []), "service", "skills"], + [], `${label} contributes`) const capabilities = value.capabilities ?? [] if (!Array.isArray(capabilities) || capabilities.length > pluginCapabilities.size || @@ -623,7 +645,8 @@ function parsePluginManifestV4(value, label) { const hasRuntime = value.runtime !== undefined const hasGeneration = value.contributes.generation !== undefined const hasService = value.contributes.service !== undefined - if (hasRuntime !== (hasGeneration || hasService)) { + const hasLlm = value.contributes.llm !== undefined + if (hasRuntime !== (hasGeneration || hasService || hasLlm)) { error(label, "runtime and executable contribution must appear together") } @@ -644,7 +667,7 @@ function parsePluginManifestV4(value, label) { : undefined const hasRenderer = canvas?.renderer !== undefined if (!hasRuntime && !hasRenderer && !capabilities.includes("generation.execute")) { - error(label, "convax.plugin/4 must declare a Plugin capability beyond owned Skills") + error(label, `${schema} must declare a Plugin capability beyond owned Skills`) } const hasEntry = value.entry !== undefined if (hasEntry !== hasRenderer) error(label, "entry and Canvas renderer must appear together") @@ -659,6 +682,7 @@ function parsePluginManifestV4(value, label) { } const service = hasService ? parseService(value.contributes.service, `${label} service`) : undefined + const llm = hasLlm ? parseLlmV5(value.contributes.llm, `${label} llm`) : undefined const skills = parseOwnedSkillsV4(value.contributes.skills, `${label} skills`) const runtime = hasRuntime ? parseMcpStdioRuntime(value.runtime, `${label} runtime`) : undefined return { @@ -667,6 +691,7 @@ function parsePluginManifestV4(value, label) { ...(agent === undefined ? {} : { agent }), ...(canvas === undefined ? {} : { canvas }), ...(generation === undefined ? {} : { generation }), + ...(llm === undefined ? {} : { llm }), ...(service === undefined ? {} : { service }), ...(skills === undefined ? {} : { skills }), }, @@ -674,7 +699,7 @@ function parsePluginManifestV4(value, label) { ...(entry === undefined ? {} : { entry }), id: parseId(value.id, `${label} id`), name: cleanString(value.name, `${label} name`, 120), - schema: "convax.plugin/4", + schema, ...(runtime === undefined ? {} : { runtime }), version: parseSemver(value.version, `${label} version`), } @@ -683,9 +708,11 @@ function parsePluginManifestV4(value, label) { export function parsePluginManifest(value, label = "manifest.json") { if (!isObject(value) || (value.schema !== "convax.plugin/1" && value.schema !== "convax.plugin/2" && - value.schema !== "convax.plugin/3" && value.schema !== "convax.plugin/4")) { + value.schema !== "convax.plugin/3" && value.schema !== "convax.plugin/4" && + value.schema !== "convax.plugin/5")) { error(label, "unsupported schema") } + if (value.schema === "convax.plugin/5") return parsePluginManifestV4(value, label, "convax.plugin/5") if (value.schema === "convax.plugin/4") return parsePluginManifestV4(value, label) if (value.schema === "convax.plugin/3") return parsePluginManifestV3(value, label) return parseLegacyPluginManifest(value, label) @@ -1528,8 +1555,8 @@ function parseCompanionArtifact(value, metadata, companion, target, label) { function parseRegistryCompanions(value, metadata, manifest, label) { if (value === undefined) return undefined if ((manifest.schema !== "convax.plugin/2" && manifest.schema !== "convax.plugin/3" && - manifest.schema !== "convax.plugin/4") || !manifest.runtime) { - error(label, "companions require a convax.plugin/2, convax.plugin/3, or convax.plugin/4 external runtime") + manifest.schema !== "convax.plugin/4" && manifest.schema !== "convax.plugin/5") || !manifest.runtime) { + error(label, "companions require a convax.plugin/2 through convax.plugin/5 external runtime") } if (!Array.isArray(value) || value.length < 1 || value.length > 16) { error(label, "must be a non-empty array with at most 16 items") @@ -1619,7 +1646,7 @@ export function parseRegistry(value, label = "Registry") { .map((entry) => [entry.id, entry])) for (const skill of packages.filter((entry) => entry.kind === "skill" && entry.ownerPluginId)) { const owner = pluginsById.get(skill.ownerPluginId) - const contribution = owner?.manifest.schema === "convax.plugin/4" + const contribution = owner?.manifest.schema === "convax.plugin/4" || owner?.manifest.schema === "convax.plugin/5" ? owner.manifest.contributes.skills?.find((item) => item.name === skill.id) : undefined if (!owner || !contribution) { @@ -1627,7 +1654,8 @@ export function parseRegistry(value, label = "Registry") { } } for (const plugin of packages.filter((entry) => - entry.kind === "plugin" && entry.manifest.schema === "convax.plugin/4")) { + entry.kind === "plugin" && + (entry.manifest.schema === "convax.plugin/4" || entry.manifest.schema === "convax.plugin/5"))) { for (const contribution of plugin.manifest.contributes.skills ?? []) { const skill = skillsById.get(contribution.name) if (!skill || skill.ownerPluginId !== plugin.id) { diff --git a/tooling/plugin-v5.test.js b/tooling/plugin-v5.test.js new file mode 100644 index 0000000..76765c0 --- /dev/null +++ b/tooling/plugin-v5.test.js @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { parsePluginManifest, parseSourceMetadata } from "./lib.mjs" + +function manifest(overrides = {}) { + return { + capabilities: [], + contributes: { + llm: { + models: [{ id: "pippit-glm-main", name: "Pippit GLM Main" }], + provider: { id: "pippit-glm", name: "Pippit GLM" }, + }, + }, + description: "External LLM provider", + id: "xiaoyunque-generation", + name: "XiaoYunque", + runtime: { command: "convax-xiaoyunque-mcp", type: "mcp-stdio" }, + schema: "convax.plugin/5", + version: "0.4.0", + ...overrides, + } +} + +describe("convax.plugin/5 LLM contributions", () => { + test("parses bounded provider and model display metadata", () => { + expect(parsePluginManifest(manifest()).contributes.llm).toEqual({ + models: [{ id: "pippit-glm-main", name: "Pippit GLM Main" }], + provider: { id: "pippit-glm", name: "Pippit GLM" }, + }) + }) + + test("keeps endpoints, credentials, and headers out of the manifest", () => { + for (const field of ["apiKey", "baseUrl", "headers"]) { + expect(() => parsePluginManifest(manifest({ + contributes: { llm: { ...manifest().contributes.llm, [field]: "private" } }, + }))).toThrow("unsupported field") + } + }) + + test("requires the v5 capability compatibility broker", () => { + const metadata = { + companions: [{ + command: "convax-xiaoyunque-mcp", + source: "packages/tools/xiaoyunque-mcp", + targets: [{ arch: "arm64", path: "dist/darwin-arm64/convax-xiaoyunque-mcp", platform: "darwin" }], + version: "0.4.0", + }], + compatibility: { pluginHost: "convax.plugin-capability/1", pluginSchema: "convax.plugin/5" }, + description: "External LLM provider", + id: "xiaoyunque-generation", + kind: "plugin", + license: "MIT", + name: "XiaoYunque", + schema: "convax.package/1", + version: "0.4.0", + yanked: false, + } + expect(parseSourceMetadata(metadata).compatibility).toEqual(metadata.compatibility) + expect(() => parseSourceMetadata({ + ...metadata, + compatibility: { pluginHost: "convax.plugin-host/4", pluginSchema: "convax.plugin/5" }, + })).toThrow("matching") + }) +}) diff --git a/tooling/registry.test.js b/tooling/registry.test.js index f12d230..f43bf5b 100644 --- a/tooling/registry.test.js +++ b/tooling/registry.test.js @@ -33,6 +33,7 @@ describe("source packages", () => { test("validates the complete Plugin and Skill catalog", async () => { const packages = await discoverPackages() expect(packages.map((pkg) => `${pkg.metadata.kind}/${pkg.metadata.id}`)).toEqual([ + "plugin/codex-service", "plugin/ffmpeg-tools", "plugin/hello-convax", "plugin/relight-studio", @@ -53,9 +54,37 @@ describe("source packages", () => { const ffmpeg = packages.find((pkg) => pkg.metadata.id === "ffmpeg-tools") const ffmpegSkill = packages.find((pkg) => pkg.metadata.kind === "skill" && pkg.metadata.id === "ffmpeg-canvas") const hello = packages.find((pkg) => pkg.metadata.id === "hello-convax") + const codex = packages.find((pkg) => pkg.metadata.id === "codex-service") const xiaoyunque = packages.find((pkg) => pkg.metadata.id === "xiaoyunque-generation") expect(hello.manifest.schema).toBe("convax.plugin/1") expect(hello.manifest.capabilities).toEqual([]) + expect(codex.manifest).toEqual(expect.objectContaining({ + runtime: { command: "convax-codex-mcp", type: "mcp-stdio" }, + schema: "convax.plugin/5", + })) + expect(codex.manifest.contributes.llm).toEqual({ + models: [ + { id: "gpt-5.6-sol", name: "GPT-5.6-Sol" }, + { id: "gpt-5.6-terra", name: "GPT-5.6-Terra" }, + { id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }, + { id: "gpt-5.5", name: "GPT-5.5" }, + ], + provider: { id: "codex", name: "Codex" }, + }) + expect(codex.manifest.contributes.generation.models).toEqual([ + { name: "GPT Image 2", tool: "image.gpt-image-2" }, + ]) + expect(codex.manifest.contributes.service.actions).toEqual(["authorize", "reauthorize"]) + expect(codex.metadata.companions).toEqual([{ + command: "convax-codex-mcp", + version: "0.1.1", + source: "packages/tools/codex-mcp", + targets: [{ + platform: "darwin", + arch: "arm64", + path: "dist/darwin-arm64/convax-codex-mcp", + }], + }]) expect(xiaoyunque.manifest).toEqual(expect.objectContaining({ capabilities: [], runtime: { command: "convax-xiaoyunque-mcp", type: "mcp-stdio" }, @@ -164,11 +193,17 @@ describe("source packages", () => { const second = await packPackages(packages, path.join(await temporaryDirectory(), "second")) expect(first.map((item) => sha256(item.zip))).toEqual(second.map((item) => sha256(item.zip))) const byId = (id) => first.find((item) => item.pkg.metadata.id === id) + const codex = byId("codex-service") const hello = byId("hello-convax") const xiaoyunque = byId("xiaoyunque-generation") const skill = byId("ad-idea") const ffmpeg = byId("ffmpeg-tools") expect(readStoredZip(hello.zip).map((entry) => entry.relativePath)).toContain("manifest.json") + expect(readStoredZip(codex.zip).map((entry) => entry.relativePath)).toEqual(["LICENSE", "manifest.json"]) + expect(codex.companionAssets.map((asset) => asset.assetName)).toEqual([ + "convax-companion-convax-codex-mcp-0.1.1-darwin-arm64", + ]) + expect(codex.tag).toBe("plugin-codex-service-v0.1.1") expect(readStoredZip(xiaoyunque.zip).map((entry) => entry.relativePath)).toEqual(["LICENSE", "manifest.json"]) expect(xiaoyunque.companionAssets.map((asset) => asset.assetName)).toEqual([ "convax-companion-convax-xiaoyunque-mcp-0.3.3-darwin-arm64", diff --git a/tooling/validate.mjs b/tooling/validate.mjs index 3410724..f971c89 100644 --- a/tooling/validate.mjs +++ b/tooling/validate.mjs @@ -45,6 +45,7 @@ export async function validateRepository(options = {}) { "convax-plugin-manifest-v2.schema.json", "convax-plugin-manifest-v3.schema.json", "convax-plugin-manifest-v4.schema.json", + "convax-plugin-manifest-v5.schema.json", "convax-registry-v1.schema.json", "convax-showcase-entry-v1.schema.json", "convax-showcase-v1.schema.json", @@ -55,7 +56,10 @@ export async function validateRepository(options = {}) { for (const skill of packages.filter((pkg) => pkg.metadata.kind === "skill")) { validateLocalMarkdownReferences(skill.files, `skill/${skill.metadata.id}`) } - for (const plugin of packages.filter((pkg) => pkg.metadata.kind === "plugin" && pkg.manifest.schema === "convax.plugin/4")) { + for (const plugin of packages.filter((pkg) => + pkg.metadata.kind === "plugin" && + (pkg.manifest.schema === "convax.plugin/4" || pkg.manifest.schema === "convax.plugin/5") + )) { for (const contribution of plugin.manifest.contributes.skills ?? []) { const prefix = `${contribution.path}/` const files = plugin.files diff --git a/tooling/workspaces.test.js b/tooling/workspaces.test.js index 9d00884..b69eaaa 100644 --- a/tooling/workspaces.test.js +++ b/tooling/workspaces.test.js @@ -35,6 +35,8 @@ describe("Bun workspace ownership", () => { test("keeps one frozen root lockfile", async () => { const lock = await readJson(path.join(root, "bun.lock")) expect(lock.lockfileVersion).toBe(1) + expect(Object.keys(lock.workspaces)).toContain("packages/tools/codex-mcp") + expect(Object.keys(lock.workspaces)).toContain("packages/plugins/codex-service") expect(Object.keys(lock.workspaces)).toContain("packages/tools/ffmpeg-mcp") expect(Object.keys(lock.workspaces)).toContain("packages/skills/ffmpeg-canvas") })