Three additions to the local provider-plugin host contract, plus the fix that makes the middle one actually reachable.
## Sparse snapshots
A plugin may now return a snapshot carrying only identity or only a cost object. Balance-only and zero-usage providers previously had no valid shape to return: an identity-only object was rejected as an empty snapshot. The menu and CLI render whichever of account, organization, plan/login-method, and account ID are present. Metadata alone (confidence, subscription dates) without displayable usage or identity is still invalid.
## Delayed retries
Transient classified failures (`rateLimited`, `providerUnavailable`, `networkFailure`, `apiFailure`) accept `{retryAfterSeconds}`, and `ProviderFetchPipeline` performs exactly one delayed retry. The delay is clamped to 10 seconds, matching the built-in transient HTTP policy, and the retry is never itself retried. Cancellation during the delay stops it.
## Approved private origins
A settings-derived endpoint may declare `policy: "https-or-private-network-http"`, which permits authenticated HTTP to loopback, RFC 1918, link-local, IPv6 unique-local/link-local, and `.local` targets. It is gated behind the existing typed-confirmation approval, so a self-hosted gateway is reachable without weakening the rule that public origins are HTTPS-only.
## The fix: user plugins could not use any of the retry contract
Live testing found the delayed-retry contract unreachable from user-installed plugins. `UserProviderPluginLoader` built the runtime with `rejectsNonSuccessResponses`, so the host threw a bare `ProviderPluginError.http("request returned HTTP 429")` inside the HTTP bridge before `fetchUsage` ever observed the response. A plugin could not inspect a `Retry-After` header, and the unclassified error carried no `retryAfterSeconds` for `ProviderFetchDelayedRetry` to act on — so user plugins never retried at all, while bundled plugins did.
The root cause was one boolean covering two unrelated policies: the status gate (reject non-2xx) and the representation gate (`Accept-Encoding: identity`, reject compressed bodies). Those are now split. `enforcesUserResponsePolicy` keeps owning the representation gate unchanged; only the status gate is relaxable.
The fix has two halves:
- **Automatic, no manifest change.** The host's own non-2xx rejection is now a classified failure carrying `retryAfterSeconds`, so every user plugin gets one delayed retry on 408, 429, 500, 502, 503, and 504 — mirroring `ProviderHTTPRetryPolicy.transientIdempotent`. A numeric `Retry-After` sets the delay, otherwise one second, clamped to ten. Every other non-2xx status keeps its existing unclassified failure and its existing message.
- **Opt-in takeover.** A plugin that needs provider-specific handling — a non-numeric `Retry-After`, quota data carried in the error body, a vendor retry field — declares the new `http-status` capability, receives the non-2xx response itself, and throws `ctx.fail.rateLimited(message, {retryAfterSeconds})`. Both paths share one retry budget.
Classification travels as the existing `__CODEXBAR_FAILURE_V2__` marker so it survives the JavaScript boundary. The JavaScriptCore bridge was rewrapping that marker as `ProviderPluginError.http` and destroying it; it now forwards the carrier intact. QuickJS, the production default, already preserved it.
### Security posture
Unchanged. The representation gate, the 1 MiB response cap, redirect rejection, origin allowlisting, and host-owned auth headers all still apply, with or without the capability — a regression test covers a plugin declaring `http-status` still failing on a gzip-encoded response. `http-status` is part of `ProviderPluginApprovalBinding`, so adding it to an installed plugin invalidates the prior approval and forces re-approval, and it renders in the approval sheet with no UI change.
## Proof
- `make test` green.
- New coverage on both engines (QuickJS and the JavaScriptCore rollback): a naive plugin with no error handling retrying a 429 once through the real loader and `UserProviderPlugin.fetchUsage`; a 503 with no `Retry-After` using the 1-second default via an injected sleeper; a 404 still failing immediately with one request; the `http-status` path unchanged; gzip still rejected under the capability.
- The pre-existing `NeuralWatt style Retry After` test built its runtime directly with the permissive default, which is how this gap survived to live testing. It now goes through the loader.
- `make check` clean; autoreview clean.
16 KiB
summary, read_when
| summary | read_when | |||
|---|---|---|---|---|
| Authoring, installing, approving, and operating local JavaScript and TypeScript provider plugins. |
|
Local provider plugins
CodexBar can load one local JavaScript or TypeScript file as a provider. Put a .js or .ts file in
~/.config/codexbar/providers/, or choose Settings → Plugins → Install…. Each file declares its complete authority
and settings schema in a manifest, fetches through CodexBar's sandboxed host API, and returns a generic usage snapshot.
Plugins are local files only. CodexBar has no plugin catalog, does not download plugin code or assets, and does not resolve imports. A plugin cannot use Node, browser globals, subprocesses, local files, databases, OAuth, WebViews, or arbitrary native APIs. The maximum source size is 1 MiB.
Minimal plugin
defineProvider({
id: "acme-usage",
name: "Acme Usage",
icon: { monogram: "AC", tint: "#336699" },
endpoints: ["https://api.example.com"],
auth: { type: "bearer", secret: "API_KEY" },
settings: [
{ key: "API_KEY", title: "API key", subtitle: "Create one in Acme settings.", type: "secure" },
],
async fetchUsage(ctx) {
const response = await ctx.http.getJSON("https://api.example.com/v1/usage");
return {
primary: {
usedPercent: response.json.used_percent,
resetsAt: response.json.resets_at,
windowMinutes: 300,
},
details: [{
title: "Usage",
rows: [{ label: "Requests", value: String(response.json.requests) }],
}],
};
},
});
Manifest reference
defineProvider must be called exactly once with an object containing:
id: 1–64 lowercase ASCII letters, digits, or hyphens. It must not match a built-in provider or another installed plugin.name: trimmed display name, 1–80 UTF-8 bytes.icon(optional):{monogram, tint}.monogramis 1–3 characters;tintis#RRGGBB. The fallback is the first letter ofnamewith a neutral tint. File/SVG icons are not supported.endpoints: 1–16 declared network origins. A fixed endpoint is a normalized HTTPS origin such ashttps://api.example.com(no path, query, fragment, or user info). A settings-derived endpoint is{setting: "BASE_URL", policy: "https"},{setting: "BASE_URL", policy: "https-or-loopback-http"}, or{setting: "BASE_URL", policy: "https-or-private-network-http"}. Its setting must be declared asplain.https-or-loopback-httppreserves the unauthenticated loopback-only rule.https-or-private-network-httpalso permits authenticated HTTP for loopback, RFC 1918 IPv4, IPv4 link-local, IPv6 unique-local/link-local, and.localtargets, but only through the separate typed approval described below. Public targets always require HTTPS.auth(optional): one of the forms below. The named secret must be a declaredsecuresetting.settings: up to 32 setting definitions. Keys contain 1–64 ASCII letters, digits, or underscores and start with a letter. Each entry haskey,title, optionalsubtitle, andtype: "plain" | "secure"(defaultsecure).capabilities(optional):"browser-cookies"and"http-status". With"http-status", the plugin observes non-2xx responses itself instead of the host failing the request.cookieDomains: required withbrowser-cookies; a non-empty list of normalized DNS host names.fetchUsage(ctx): function returning a snapshot object or a promise for one.
Authentication forms:
auth: { type: "bearer", secret: "API_KEY" }
auth: { type: "x-api-key", secret: "API_KEY" }
auth: { type: "header", header: "X-Custom-Key", secret: "API_KEY" }
auth: { type: "authorization-scheme", scheme: "Token", secret: "API_KEY" }
The host owns the authentication header; plugin request options cannot override it. Authenticated public origins must be
HTTPS; authenticated private-network HTTP requires https-or-private-network-http plus typed approval. Secure settings
can be overridden for CLI use with
CODEXBAR_PLUGIN_<PLUGIN_ID>_<SETTING_KEY>, uppercased with non-alphanumeric characters replaced by underscores. For
example, acme-usage and API_KEY use CODEXBAR_PLUGIN_ACME_USAGE_API_KEY.
ctx API
ctx exists only during fetchUsage. CodexBar uses QuickJS on every platform; both QuickJS and the Apple-only
JavaScriptCore rollback engine provide ECMAScript built-ins but no browser or Node environment. Intl is
engine-dependent and unavailable in QuickJS,
so portable third-party plugins must use the host helpers below instead of ECMA-402. fetch, XMLHttpRequest, timers,
require, process, and filesystem APIs are unavailable.
await ctx.http.getJSON(url, opts?)performs GET and returns{status, headers, json}.await ctx.http.get(url, opts?)performs GET and returns{status, headers, bodyText}.await ctx.http.postJSON(url, {body, headers?})performs JSON POST.bodymust be JSON-serializable.opts.headersaccepts string values. Plugins cannot replace their declared auth header.opts.timeoutSecondssets a hard request deadline from 1 through 30 seconds; the default is 15 seconds.ctx.settings.get(key)reads a declaredplainsetting.ctx.settings.getSecret(key)reads a declaredsecuresetting. Missing values returnnull; kind mismatches and undeclared keys throw.ctx.failcreates classified errors forauthenticationExpired,missingCredential,permissionDenied,rateLimited,providerUnavailable,parseFailure,networkFailure, andapiFailure. Throw the returned error, for examplethrow ctx.fail.rateLimited("Provider rate limit reached"); ordinary errors retain generic mapping. Every plugin automatically gets one delayed retry when a request returns 408, 429, 500, 502, 503, or 504. A numericRetry-Afterheader sets the delay; otherwise the delay is 1 second, and the host clamps it to 10 seconds. A plugin that needs provider-specific handling—such as a non-numericRetry-After, quota data in the error body, or a vendor retry field—declareshttp-status, receives the response, and throwsctx.fail.rateLimited(message, {retryAfterSeconds})or another transient classified failure. Both paths share one retry budget and never retry the retry. Cancellation during the delay stops the retry.await ctx.browser.cookieHeader(domain)returns a cookie header only with thebrowser-cookiescapability and for a declared domain. The app imports from Chrome only. Cookie values are secret-equivalent and redacted.ctx.html.metaContent(html, name)returns the first matching quoted meta value ornull.ctx.html.matchFirst(html, regexSource, flags?)returns the first capture/full match ornull.ctx.log(...values)writes to the instance-scoped plugin log. Known secrets and cookie values are redacted.ctx.cache.get(key)andctx.cache.set(key, value, ttlSeconds)provide a per-runtime memory cache. TTL is capped at 24 hours.ctx.date.now(),iso(text),unixSeconds(number), andunixMillis(number)create JavaScript dates.now()uses the host refresh clock.ctx.date.nowMillis()returns the same host refresh clock as Unix epoch milliseconds — use it for arithmetic that should stay deterministic under fixture clocks (the z.ai quota-rate row does).ctx.date.nextDailyReset(timeZoneIdentifier, hour)returns the next wall-clock reset in an IANA time zone.ctx.env.timeZoneis the host's current IANA time-zone identifier; zero-offset GMT aliases are normalized toUTC.ctx.format.number(value, options?),usd(value), andmonthDay(date)provide deterministic formatting on both engines. Number options supportminimumFractionDigitsandmaximumFractionDigits.ctx.jwt.decode(token)decodes (but does not authenticate) a JWT JSON payload.ctx.pct(used, limit)returns a finite percentage clamped to 0–100; non-positive limits map to 100.
User-plugin requests run in an ephemeral session with no ambient cookies, credential store, or URL cache. Redirects are
rejected, the timeout is 15 seconds, Accept-Encoding: identity is sent, compressed responses always fail, and response
bytes are capped at 1 MiB. By default, the host rejects non-2xx responses and automatically retries 408, 429, 500, 502,
503, and 504 once, using a numeric Retry-After delay or 1 second when absent, clamped to 10 seconds. With http-status,
the plugin instead receives {status, headers, ...} and owns classification, including any request for the same single
delayed retry. Request URLs must match a declared, approved origin.
capabilities: ["http-status"],
async fetchUsage(ctx) {
const response = await ctx.http.getJSON("https://api.example.com/usage");
if (response.status === 429) {
const retryAfterSeconds = Number(response.headers["retry-after"] || 1);
throw ctx.fail.rateLimited("Rate limited", { retryAfterSeconds });
}
}
Declaring http-status changes the approval binding, so an installed plugin requires re-approval after adding it.
Bundled first-party providers that have cut over to JavaScript use the shared runtime's 20-second hung-script watchdog.
A timeout fails that refresh and discards the poisoned worker so the next refresh starts with a fresh context; this is
production-default and does not depend on CODEXBAR_JS_PROVIDERS.
QuickJS enforces the watchdog in-engine with JS_SetInterruptHandler, caps the runtime heap at 64 MiB, and caps the
JavaScript stack at 2 MiB. The interrupt terminates evaluation on its confined thread; timed-out scripts do not leave an
abandoned evaluation thread behind. On Apple platforms, CODEXBAR_PLUGIN_ENGINE=jsc selects the JavaScriptCore rollback
engine; the same rollback is available in Settings → Debug → Provider Plugins and takes effect after restarting
CodexBar. JavaScriptCore has no public interrupt API, so a timed-out rollback-engine context is discarded but its
abandoned evaluation thread can remain alive until process exit.
Snapshot result
Return at least one rate window, cost object, detail section, or non-empty identity field:
return {
primary: { usedPercent: 25, resetsAt: new Date(), windowMinutes: 300 },
secondary: { usedPercent: 40, resetsAt: "2026-08-10T00:00:00Z", windowMinutes: 10080 },
tertiary: { usedPercent: 5 },
extraWindows: [{ id: "daily", title: "Daily", window: { usedPercent: 12 } }],
cost: { used: 8.5, limit: 20, currency: "USD", period: "This month", balance: 11.5 },
identity: { email: "user@example.com", organization: "Acme", loginMethod: "API key", accountID: "123" },
subscriptionRenewsAt: "2026-09-01T00:00:00Z",
dataConfidence: "exact", // exact | estimated | percentOnly | unknown
details: [{
title: "Usage summary",
rows: [{ label: "Requests", value: "1,240", secondaryValue: "Last 30 days" }],
chart: {
kind: "bars", // bars | line
title: "Daily spend",
unit: "USD",
points: [{ label: "2026-08-01", value: 4.25 }],
},
}],
};
Percentages must be finite and are clamped to 0–100. Window minutes are positive integers. Cost requires finite used
and a three-letter uppercase currency. Dates are JavaScript Date values or ISO-8601 strings. Snapshot identity is
always scoped to the manifest's instance ID. Data confidence defaults to unknown. Details allow at most 8 sections, 24 rows per section, 120 chart points,
and 120 characters per detail string. Wrong types and limit violations fail the whole fetch instead of truncating it.
An identity-only snapshot is useful for balance-only or zero-usage provider states and renders its available account,
organization, plan/login-method, and account-ID fields in the menu and CLI. An empty object, an empty identity object,
or metadata such as confidence and subscription dates without displayable usage or identity remains invalid.
TypeScript
codexbar-plugin.d.ts is the canonical authoring
contract for defineProvider, the ctx host API, manifests, and usage snapshots. Bundled plugins may use that contract
directly as .ts sources. Scripts/regenerate-plugin-js.sh transpiles them with the vendored Sucrase build into
committed sibling .js files; the runtime continues to load only those JavaScript files, so bundled TypeScript has no
runtime compilation cost. make check verifies both the TypeScript contract and generated-file freshness.
For bundled-plugin work, run make format after editing TypeScript so the committed JavaScript is regenerated. Do not
edit a generated sibling .js file directly.
TypeScript files are transpiled by the selected plugin engine with the bundled Sucrase 3.35.1 build using its
typescript transform. Use ordinary
type syntax but no module imports, JSX, decorators, or runtime TypeScript features that require module resolution.
Transpiled output is cached in ~/Library/Caches/CodexBar/plugins/ under a filename containing the SHA-256 of the source
and the Sucrase version. An unchanged file is a cache hit; any source or compiler-version change produces a new key.
Transpile failures appear as that plugin's Settings error.
Install, approve, run, and delete
- Open Settings → Plugins and choose Install…, or copy one
.js/.tsfile into the providers directory. - CodexBar validates the source and manifest without network, file, cookie, or secret capabilities.
- The approval sheet lists exact normalized origins, auth mode, capabilities, secure setting names, and cookie domains.
- For loopback, IP-literal, or
.localorigins, type every normalized origin exactly before approval. - Enter manifest settings and enable the plugin. Its refresh result appears in its generic menu card.
Approval records live outside plugin files under ~/Library/Application Support/CodexBar/plugin-approvals.json. A
change to instance ID, normalized origins, auth mode/header, secure setting names, capabilities, or cookie domains
invalidates approval before the next request. There is no bulk approval or import path.
Bundled first-party plugins do not use the interactive plugin-approval flow. The private-network HTTP policy is therefore accepted for bundled code only for LLM Proxy and LiteLLM, whose existing Swift providers already permit exactly those targets. Other bundled providers fail manifest validation if they request that policy.
codexbar plugins list shows locally discovered plugins. codexbar plugins fetch <id> displays the same approval
fields and can approve only from an interactive terminal; redirected/headless input fails closed. Browser-cookie plugins
are app-only and fail closed in the CLI.
Delete from Settings with Delete…. CodexBar removes the plugin file, matching TypeScript cache output, approval, per-instance settings and secrets, and per-instance usage history. Invalid plugin files are listed with their validation error and can also be deleted.
Security and limitations
Treat a plugin like code you run locally, even though its host capabilities are narrow. Read the manifest and source, verify every origin, and avoid installing files from untrusted repositories. Approval grants the listed origin network authority; DNS changes after approval are outside CodexBar's threat model. Secrets are never placed in URLs or logged, redirects cannot forward authentication, and undeclared settings/cookies/origins fail closed.
Plugins support the macOS app plus the macOS and Linux CLIs. They are excluded from widgets and all built-in-provider-only surfaces (status feeds, token accounts, OAuth, browser automation, storage probes, local cost scanners, and provider specific payloads). Rendering is limited to generic snapshots and declarative details. There are no remote catalogs, downloaded plugins/assets, custom SVGs, imports, arbitrary local I/O, or compatibility fallback from an unknown ID to a built-in provider.