Skip to content

v1.6.3

Choose a tag to compare

@github-actions github-actions released this 28 Aug 21:31
· 48 commits to main since this release
2ddfa29

Patch Changes

  • #1560 8c20c33 Thanks @Adityakk9031! - Stale tool catalogs refresh together instead of one after another, and self-host can set the freshness window

    A tools read rebuilds every connection whose catalog has gone stale. Those rebuilds each dial their own upstream, but ran strictly one after another, so a host with several stale remote catalogs paid the sum of every server's latency on the read that tripped the TTL. The upstream listings now run concurrently, bounded so a large stale set cannot open an unbounded number of listings from one read.

    Only the listings overlap. Each rebuild's catalog write stays single-file, because a self-host database is one connection issuing raw BEGIN/COMMIT and a second transaction opened while one is live fails outright. A rebuild that fails now also logs a warning naming the connection and the reason, instead of disappearing: the read still succeeds on the stale-but-working catalog and the other connections still finish, but a permanently broken connection no longer re-fails silently on every read.

    Self-host also exposes the freshness window as EXECUTOR_TOOLS_SYNC_TTL_MS. Leave it unset for the 15-minute default, or set off (equivalently null or false, in any case) to disable time-based re-sync and leave stale-marking and config revision as the only refresh triggers. The value forwards to the SDK verbatim, so 0 keeps its SDK meaning: every catalog is expired on every read. A malformed, negative, or too-large-to-represent value is refused at boot rather than silently falling back to the default.

  • #1572 27cb466 Thanks @GeiserX! - Irreversible cleanup now waits for the transaction to commit, and plugins can do the same

    oauth.removeClient deleted the client row and then deleted the client secret from the credential provider. The provider does not enlist in the caller's transaction and does not roll back with it, so an abort restored the client row while its secret stayed destroyed — a client that looks configured and can never authenticate again. The deletion now waits until the removal is durable and is discarded if the removal rolls back. With no transaction active it runs immediately, exactly as before.

    Deferring the deletion is not enough on its own. The secret is stored under a key derived from the app's (owner, slug) identity alone, so the key outlives the row it belonged to: whoever holds that identity when the deletion finally runs owns the key. A slug registered again before the removal committed would lose the new app's secret to the old app's queued deletion — the same unauthenticatable client, reached the other way round. The deferred deletion now re-checks that the app is still gone and stands down when it is not. A removal that matched no row also no longer queues a deletion at all: it removed nothing, so it has no claim on the key, which may well hold another subject's live secret.

    The same trap was reachable by plugins and they had no way out of it. removeConnection and removeIntegration run inside core's removal transaction — deliberately, so a plugin's own rows die atomically with the connection — which makes them exactly the wrong place to revoke a token at the provider's API, delete a remote object, or notify a third party. Nothing in the hooks' documentation said so, and PluginCtx exposed transaction but nothing to defer past it.

    PluginCtx gains afterCommit. It runs the effect once the outermost transaction commits, discards it if that transaction rolls back, and runs it immediately when no transaction is active. The lifecycle hooks now document that they run inside core's transaction and that outside-world work belongs in afterCommit.

    Sequencing work after your own transaction(...) call is not equivalent, and the documentation says so explicitly: transaction nests by pass-through, so inside an active transaction the inner call simply runs its effect and "afterwards" is still before any commit.

  • #1569 d874455 Thanks @GeiserX! - Abandoned authorization sessions no longer keep their PKCE verifier forever

    An OAuth authorization session stores its PKCE verifier so the callback can redeem the code. complete discarded an expired session lazily, but an abandoned flow is never completed, so that check never ran for it and nothing else swept the table — the verifier sat there in plaintext indefinitely.

    Starting a new authorization now sweeps sessions that have already expired. Doing it on start bounds the table by how often authorization is begun rather than by how often it is abandoned, and needs no scheduler in any host. A session whose completion cannot be retried is dropped rather than left behind.

    The sweep only ever reaches rows the caller can already see, so one member's authorization never touches another member's sessions. It is best-effort: a sweep that fails logs a warning and lets the authorization continue.

  • #1825 06a7b75 Thanks @RhysSullivan! - A console URL naming an organization you cannot see is a not-found page, every time

    Opening /<some-other-slug>/policies sometimes rendered the full authenticated console — sidebar, org switcher showing your OWN organization, page chrome — under an address naming an organization you are not a member of. The page body was a failing org-scoped query with a Retry button, so the workspace on screen belonged to nobody and the URL belonged to someone else.

    The shell's not-found only fired once /account/me had answered for the URL's slug. Until then the console read its identity from the auth-hint cookie, which always names the organization the session last landed in, never the one in the address bar. So the first paint answered a question about a different organization and built a whole workspace out of it, and whether you ever saw that depended on how fast the server replied.

    The shell is now built only from an answer that names the organization the URL names. A slug the current answer does not cover renders nothing at all until /account/me resolves for that slug, and then either the workspace or — for an organization this session cannot see — the not-found page. The URL is never rewritten: a wrong address stays a wrong address.

    The ordinary cold load is untouched. The hint names the slug already in the URL, so it matches on the very first paint and the shell renders with no round trip. Only a slug the hint does not name waits: a foreign one, and the single frame after switching organizations, which now paints the organization the URL asked for instead of briefly showing the previous one.

  • #1575 e5526f3 Thanks @GeiserX! - GraphQL introspection no longer logs a credential carried in the endpoint URL

    query is a supported credential carrier, so a GraphQL endpoint can be reached with ?token=<secret>. Introspection built its request from a URL string, and HttpClientRequest.setUrl keeps a string verbatim as request.url. Every HttpClientError renders ${method} ${request.url} into its message getter, and introspection logs the raw failure cause — so on any transport failure or non-JSON response, the connection's secret was written to the process log.

    The request is now built from a URL object, which moves the query into request.urlParams and clears it from request.url. The secret is therefore absent from the error message, and from anything else that renders the request URL. The credential still reaches the upstream: the client recombines url and urlParams when it executes the request. The endpoint's own query string is handled the same way, not just the separately-supplied query parameters, since a configured endpoint can carry a credential too.

    The query string is now normalized on the wire. Recombination appends each pair through URLSearchParams, so the query is re-serialized in form-urlencoded form instead of passed through byte-for-byte:

    • a space written as %20 is sent as +
    • ~ and !'() are percent-encoded
    • a valueless ?flag is sent as flag=

    Key order, repeated keys, and already-encoded reserved characters are unchanged, and every parameter still decodes to the same value. This is not avoidable while the fix holds: raw query bytes only survive inside request.url, which is the one field every error message renders, so byte-transparency and keeping the credential out of the log cannot both hold. An upstream that signs its raw query string is the case to watch. The exact resulting URLs are pinned by test.

    Two endpoints are now rejected up front with an invalid-endpoint failure rather than dialed:

    • an endpoint that is not a valid URL, which cannot be split this way and would otherwise be sent without the query parameters it was asked to include
    • an endpoint carrying userinfo (https://user:pass@host/…), which URL keeps in the origin, so it would stay in request.url and leak into error messages exactly the way a query-carried secret used to

    Neither rejection echoes any part of the endpoint. A health check on such an integration now reports the invalid configuration and points the operator at the endpoint URL, instead of blaming the credential that was never sent.

  • #1596 b77ee69 Thanks @GeiserX! - Credentials are kept out of the health-check result that gets persisted

    A health check stores a sample of the probed operation's response body, plus the extracted identity, in connection.last_health — so whatever those carry is written to the database. The operation is user-chosen from the plugin's catalog, which means it can just as easily be a key-listing endpoint as a /me, and those return secrets that no scrub of the connection's own credential value can recognise, because they are different secrets entirely.

    Two passes now cover both kinds of secret:

    • By key name. Leaves whose key names a credential (token, api_key, secret, authorization, session, …) have their value replaced with [redacted]. The row itself is kept, so the live preview still shows the response shape and the identity picker still works. Keys that merely contain a matching substring, such as author, are left alone. camelCase spellings are recognised too: accessToken, refreshToken, clientSecret, privateKey and sessionId have no separator before the credential word, so a matcher that only looks for one reads them as innocent.
    • By value. The OpenAPI health check removes the connection's own credential value from each sampled value, covering the other direction: a body that echoes back the key it was authenticated with under an innocent-looking name. This runs before the sample's 120-char truncation, not after — truncating first leaves a prefix of a long credential that an exact-value scrub can no longer match, and that prefix is what would be persisted.

    The key check reads a dotted path two ways. It uses the nearest NAMED segment, because array elements are named by index: {"tokens": ["sk-live-…"]} produces the path tokens.0, and testing the literal "0" matches nothing. It also uses an enclosing array container, because a key listing returns {"api_keys": [{"value": "sk-live-…"}]}, whose path is api_keys.0.value — the nearest named segment there is the innocent value, and only the array's own key says what the collection holds. A collection whose key names nothing, such as names.0, is still shown in full.

    The extracted identity goes through both passes as well. It is read straight off the raw body, so it previously bypassed them even though it is persisted the same way, and identityField is user-chosen from whatever the picker listed.

  • #1823 d74865f Thanks @RhysSullivan! - Saved integrations come back after an upgrade left an OAuth expiry in the old number format

    Some installs lost every saved integration from the MCP gateway at once. The credentials were never deleted — the gateway simply could not read the table they live in, so it served an empty tool list and restarting did not help.

    The connection.expires_at column records when an OAuth access token expires. It used to be a plain number; it now holds the value's digits, because a millisecond timestamp is larger than a 32-bit integer. SQLite does not rewrite rows when a column's type changes, so a connection saved by an older build still held the old form. Reading one back failed, and because the failure happened while mapping the row, it failed the whole query rather than that one field — one stale row was enough to hide every integration.

    A boot-time migration now converts those values to the current form. It runs before anything reads the table, so the integrations are back on the first restart after upgrading. It only touches values still in the old numeric form: rows already written by a current build are left exactly as they are, and it runs once.

  • #1607 75c917f Thanks @timkley! - Fix: allow MCP integrations to declare OAuth scopes when resource metadata omits them

    MCP OAuth methods can now carry an optional non-empty scope list. Declared scopes
    take precedence over protected-resource scope discovery, so servers with fixed
    scopes can connect even when their dynamically registered OAuth client has no
    resource identifier. Existing integrations without declared scopes keep
    discovering them from the server at connect time.

  • #1577 4879d47 Thanks @GeiserX! - Idle MCP connections age out on the pool's next acquire, even when their identity is never dialled again

    The pool's five-minute idle window was only consulted against the entry being requested, so an identity that was never asked for a second time was never examined a second time. Its session stayed open and authenticated for as long as the pool lived, holding the bearer token or API key it was dialled with. The advertised bound applied only to connections that happened to be reused.

    acquire now sweeps every entry past the window, closing each one, rather than just the entry matching the key. This stays lazy in the sense the pool intends — activity drives it, there is no timer and no background fiber — and the map holds at most one entry per identity, so the scan is trivial.

    Because the sweep is paid for by whichever invocation acquires next, it cannot be allowed to stall that caller. The expired entries leave the pool synchronously, before any close is awaited, and the closes then run concurrently with each one bounded by a two-second timeout — so a server that accepts a close and goes quiet is abandoned rather than waited on, and cannot hold up an unrelated request or the connections queued behind it.

    Reuse is unchanged: an entry still inside the window is left alone, and a second call for the same identity still gets the parked session rather than a fresh dial.

  • #1821 435c0f2 Thanks @RhysSullivan! - Fix: add remote MCP servers that sit behind an authenticating proxy

    The add-MCP form now carries an optional request headers editor. The name/value
    pairs are sent on the connection check and on every later request, so an
    endpoint gated by an edge authenticator — a Cloudflare Access service token,
    for example — can be discovered and added.

    A 403 from such a gate is also no longer read as an unreachable server. It is
    classified the same way a 401 is: the endpoint needs credentials, so the add
    flow continues to the auth step instead of stopping on "Couldn't reach this
    URL".

  • #1775 ecb87de Thanks @The-AarushiSingh! - Stdio MCP integrations can be edited from the UI

    The integration Edit sheet showed stdio servers as read-only text and told you
    to remove and recreate the integration to change its command. Fixing a typo in
    an argument, moving a server to a new path, or adding a static environment
    variable meant editing executor.jsonc by hand, or losing the integration's
    connections and tool policies to a delete-and-re-add.

    The sheet now edits the command, its arguments, the working directory, and the
    declared environment map, staged and applied by the sheet's own Save like the
    remote editor beside it. Arguments use the same quote-aware parsing as the add
    flow, so an argument containing spaces survives a round trip.

    The environment field edits the DECLARED static variables only. A stdio server
    receives those plus a small fixed base set — it does not inherit executor's
    environment — and secret values still belong to the connection, entered per
    account against the server's declared stdio_env method.

    Saving revises the integration config, which is already enough to rebuild the
    tool catalog: connections whose catalog predates the revision re-list on their
    next read, so an edited command's tools are correct without an explicit
    refresh.

  • #1402 742a144 Thanks @RhysSullivan! - A disabled upstream API now reports misconfigured instead of expired

    A 403 caused by the provider disabling the API (Google SERVICE_DISABLED / accessNotConfigured shapes) is a configuration problem, not a credential problem — reconnecting cannot fix it. Health checks now classify it as a fifth status, misconfigured, shown with an amber badge and a link to the provider console instead of a Reconnect prompt. Ordinary 401/403 credential rejections still report expired.

  • #1377 98615b4 Thanks @RhysSullivan! - A credential-store outage no longer costs an OAuth connection its grant

    Refreshing an OAuth token spends the stored refresh token: the authorization server rotates it, so the copy we sent stops working the moment the grant succeeds and the rotated one is the only thing that can mint again. Persisting the rotated token first bounds what a partial write can lose, but it cannot help when the store is refusing writes outright — the grant has already run, there is nowhere to put the successor, and every later refresh replays a token the server has revoked. The connection then reports invalid_grant and demands a re-auth over what was only a storage blip.

    The refresh is now gated on a store that is proven writable. Before the grant runs, it writes a fixed value to an item of its own that holds no credential and sits in the same partition as the connection's tokens. A store that cannot take that write fails the resolve while the stored refresh token is still valid, so the connection recovers on its own once the store does.

    The probe deliberately does not test the store by rewriting the refresh token with the value it just read. That is a read-then-write with no compare-and-set, and two instances refreshing one connection would lose the newer token to it: one reads the stored token, the other spends that same token and stores its rotated replacement, and the first then writes the spent one back over the replacement. The connection would die exactly the way the gate is meant to prevent.

    The probe also removes a write rather than adding one in the common case. Authorization servers that do not rotate hand back the same refresh token, and that value is no longer re-persisted when it has not changed — a rotated token never matches, so the write that matters still happens.

  • #1822 d7e4b73 Thanks @RhysSullivan! - An OAuth app can now be registered without an RFC 8707 resource, and that absence holds on every request

    Microsoft Entra v2 rejects any authorization request that carries both a v2 scope (such as https://api.fabric.microsoft.com/.default) and the RFC 8707 resource parameter, failing with AADSTS9010010 before the consent screen. Executor made that unavoidable for MCP servers behind Entra: registering an app for an MCP integration always derived the MCP endpoint as the resource, the form had no field to change it, and so every request carried the parameter Entra rejects.

    The register/edit OAuth app form now shows the resource indicator. It is still prefilled for MCP servers — nothing changes for providers that accept the parameter — but it can be cleared, and a cleared value persists as "no resource". A resource-less app then omits resource on all four grants alike: the authorization request, the code exchange, token refresh, and client-credentials. Symmetry matters here — sending resource on authorize but not on the token request (or the reverse) would bind the two tokens to different audiences.

    Two adjacent gaps closed with it:

    • MCP scope discovery no longer depends on the app's resource. It now falls back to the integration's own discovery URL (the MCP endpoint), so clearing the resource does not break connecting.
    • Token refresh for a first-party OAuth app dropped the app's configured resource, refreshing to a different audience than the original grant. It now sends the same resource the authorization request sent.

    Apps that keep their resource — the default for every discovered MCP server — behave exactly as before: the parameter is sent on every grant, as the MCP authorization spec expects.

  • #1567 6225d5e Thanks @GeiserX! - Keep token material out of OAuth token-endpoint error messages

    A token-endpoint failure renders a preview of the upstream body into its
    message, and that message is persisted onto connection health, returned to the
    caller, and carried into telemetry. On a malformed HTTP 200 the body being
    previewed is a successful token response, so an access token and a refresh
    token could be rendered into it.

    The preview is now built from an allowlist of fields that are safe to show
    (error, errors, error_description, error_uri, plus code, message,
    and detail nested inside them) instead of a denylist of fields to hide. A
    field nobody anticipated is omitted by default rather than printed by default.
    Keys stay visible and only non-allowlisted string values are replaced, so an
    operator can still read the shape of what the server sent. code is readable
    only when nested, because at the top level of a token response it is the RFC
    6749 authorization code.

    Form-encoded bodies take the same allowlist, the walk over a body is
    depth-bounded, and the failure summary records the token endpoint's hostname
    rather than its full URL, which can carry identifiers in its path.

    On that same malformed-200 path the failure no longer keeps the underlying
    rejection as its cause. That rejection carries the parsed token response, so
    keeping it put the raw tokens back into anything that renders the whole failure
    rather than only its message. Everything the path needs from the body — the
    status, the error code, the redacted preview — is read before the failure is
    built. A transport failure still keeps its cause, which is what tells a DNS miss
    apart from a refused connection.

    No public API changes. The dead-grant classification added for HTTP 200 refresh
    refusals is unaffected: it reads the HTTP status, not the rendered preview.

  • #1829 369fa0a Thanks @RhysSullivan! - The 1Password provider can now be scoped to several vaults, with explicit per-vault addressing

    The provider previously bound exactly one vault. The configuration now holds a set of vaults selected with checkboxes, and every reference is explicit about which vault it means: the item picker is a searchable list that shows each item's vault and stores a vault-qualified op:// reference, so identically-titled items in different vaults can never collide. A bare item name is accepted only when it matches exactly one item across the selected vaults — a name that exists in more than one place fails with an error naming the matching vaults instead of silently picking one.

    Reopening the vault or item pickers no longer flashes a loading state: listings are retained and re-validated in the background, so the last-known list renders instantly.

    Configurations saved before this change keep working: the stored single-vault shape is read as a one-vault list and upgrades to the new shape the next time it is saved. The status tool reports vaultNames for all configured vaults and flags any configured vault the account can no longer see. Provider entries also gained an optional group label, which pickers use to show where an item lives.

  • #1772 597ce90 Thanks @baggiiiie! - Persist the Connect-an-agent card's transport, artifact, integration-search, and approval preferences in the browser so its generated MCP install command remains stable across page reloads.

  • #1529 0004ea3 Thanks @jadch! - Keep Last-Event-ID recovery scoped to its originating MCP stream.

  • #1441 a9bf623 Thanks @salmonumbrella! - Self-hosted instances can now allow additional browser origins without changing
    their canonical public URL. Set EXECUTOR_TRUSTED_ORIGINS to a comma-separated
    list of exact HTTP or HTTPS origins when one instance is intentionally reachable
    through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval
    links, and other generated URLs remain pinned to EXECUTOR_WEB_BASE_URL, and
    origins are never inferred from request headers.

  • #1720 2da4953 Thanks @Zeko369! - Connect an integration from the sidebar

    The sidebar lists your integrations on every console route, but connecting
    another one meant navigating back to the integrations page to reach its Connect
    action — the picker state was owned by that page, so the shared shell could
    render the list without being able to open the flow behind it.

    The connect dialog now belongs to the shell. A labelled plus button sits beside
    the sidebar's Integrations heading and opens the same picker, records the same
    event, and leaves the current route in place behind it. On mobile the navigation
    drawer closes first so the dialog gets the full viewport.

  • #1824 97ff388 Thanks @RhysSullivan! - Tools reads stop waiting on slow upstream servers

    A tools read rebuilds every connection whose catalog has gone stale before answering. The rebuilds already ran concurrently, but the read still waited for all of them, so one slow or unreachable MCP server gated every catalog read behind its network timeout — a tools listing could take tens of seconds while healthy connections sat ready.

    A read now waits at most a short grace budget (2 seconds by default) for the rebuilds, then answers from the persisted catalog. The rebuilds keep running after the read returns and land on a later read, so the catalog still converges — it just no longer holds the reader hostage while it does. Overlapping reads share one in-flight rebuild per connection instead of stacking new ones.

    The budget is toolsSyncGraceMs on the SDK config. Pass null to restore the strict behavior, where a read blocks until every rebuild finishes and always reflects a fully converged catalog.

  • #1819 1cd81d6 Thanks @RhysSullivan! - Fix: stop the update check from claiming a newer version is available on builds it cannot compare

    A build stamped with the placeholder 0.0.0 version always compared as older
    than the latest release, and a prerelease on a channel with no matching
    dist-tag (rc, alpha, and similar) always lost the comparison too. Both cases
    now short-circuit to "no update available" before the check reaches the
    registry.

    This applies wherever the update check runs, so the CLI check and the sidebar
    update card both stop showing an update prompt that a user could never act on.

  • Updated dependencies [1908dd6, c1f51b7, 02b52cd]:

    • @executor-js/local@1.6.3
    • @executor-js/sdk@1.6.3
    • @executor-js/api@1.4.66
    • @executor-js/runtime-quickjs@1.6.3