v0.8.2: rabbitmq, code hygiene, new kb connectors - #6718
Conversation
* feat(rabbitmq): add RabbitMQ integration * fix(rabbitmq): strip auth on redirect, require https, and bound the retrieval response * fix(rabbitmq): reserve message metadata in the retrieval response budget
* feat(integrations): add Azure Data Explorer Add a 14-operation Azure Data Explorer (Kusto) integration covering KQL queries, schema and metadata discovery, table management, inline and query-sourced ingestion, ingestion-failure triage, and arbitrary management commands. Authentication uses a Microsoft Entra service principal through an internal proxy route, since the Kusto token audience is per-cluster and cannot be expressed as a static-scope OAuth provider. * fix(azure-data-explorer): only read partial-failure status from the QueryStatus table Scanning every returned table for Severity and StatusDescription columns misread an ordinary query as a failed request whenever the user's own result selected columns of those names — a common shape for a log table. Failure detection now consults only the table the response's table of contents names as QueryStatus, and primary-result selection reuses the same index instead of re-reading it. * fix(azure-data-explorer): keep the Show Operations and Show Table Details cards from painting empty check:canvas-sentences flagged the Show Operations sentence: it anchored `core` on operationId, which is an advanced-mode optional field, so an untouched card resolved to nothing and painted empty. Show Table Details had the same shape in milder form — table is optional there, since omitting it describes every table, leaving a dangling preposition. Both now lead with literal copy and treat their field as an optional refinement. Also simplifies the primary-table condition to a single `!= null` check. * fix(azure-data-explorer): authenticate sovereign clusters against their own Entra authority The cluster allowlist accepted Azure China and US Government hosts, but every token request went to login.microsoftonline.com. Those clouds are isolated instances with their own Entra endpoints, so a sovereign cluster passed URI validation and then could never obtain a token. Each Kusto service domain is now declared alongside the authority that issues tokens for it, so the two cannot drift apart, and the authority is part of the token cache key. * improvement(azure-data-explorer): warn that ingest-from-query matches columns by position Kusto aligns an ingested query result to the target table on column type and order, never on column name, so a query projecting the right columns in the wrong order lands data in the wrong columns without erroring. Surfaces that in the tool description and param the model reads, in the wand prompt that generates the query, in the rollup skill's steps, and in the docs. Also verifies the target schema first rather than after. * chore(azure-data-explorer): drop the unsourced kustomfa host from the cluster allowlist Every other entry traces to a Microsoft reference — the Kusto connection-string doc, the national-cloud endpoint tables, and the Fabric KQL-database REST reference. kustomfa.windows.net does not, and the connection-string doc states the trust boundary as hostnames ending in kusto.windows.net. An allowlist should only hold hosts we can justify, so this drops it and records the sourcing standard for anything added later. * fix(azure-data-explorer): handle commas inside quoted properties and empty extent IDs Two defects in the shared command helpers: buildWithClause split the property list on every comma before validating, so a value that legally contains one — a docstring sentence, or a tags array with more than one entry — was torn in half and rejected. Splitting is now quote-aware, and an unterminated quote is rejected outright rather than swallowing the rest of the clause. transformColumnListResponse dropped empty strings, but `.ingest inline` reports "no data shards were generated" as a single record carrying an empty extent ID. A no-op load therefore looked like a missing column instead of an empty result. Only non-strings are skipped now.
…ion (#6705) Condition expressions pasted every environment variable value into the expression as source. Block references in the same expression go through a proper escape and get quoted; env vars went through neither. That left three defects: - A bare string placeholder was a SyntaxError. `{{NAME}} === 'alice'` resolved to `alice === 'alice'`, so the form the Function block docs recommend could not be used here at all. - Ordinary data broke the block. An apostrophe (`O'Brien`) or a newline in a legitimate value produced unparseable source and failed the run. - The quoted form was injectable. A value of `x' || true || '` turned `'{{NAME}}' === 'bob'` into `'x' || true || '' === 'bob'`, forging a true branch out of a comparison that should be false. Inline only structurally inert literals — numbers, booleans, and null, with optional space/tab padding. Every other value keeps its `{{NAME}}` placeholder and is bound as a string by the execution-boundary compiler, the same one Function blocks and Custom Tools already use. Legacy outcomes are preserved. `{{COUNT}} === 3` and `{{ENABLED}} === true` still compare as literals, and an embedded `"Bearer {{API_KEY}}"` still compares equal — now via compiled concatenation rather than a pasted value. Padding is admitted rather than trimmed so the inlined text stays byte-identical to the stored value, which is what keeps a padded number correct both bare and quoted. A resolved secret also no longer travels to the execution boundary inside the condition source. The one deliberate behavior change: a value whose text is itself a quoted JS literal (a secret stored as `'foo'`, a plausible workaround for the bare-string SyntaxError) now compares as the 5-character string rather than as source. That form is the injectable one, so it cannot be kept. Docs: state the placeholder type contract, which was described mechanically but never in terms of what a reader gets. `{{KEY}}` in Function and Custom Tool code always evaluates to a string, so a bare `if ({{FLAG}})` is always true and a list has to be stored as JSON. This is what a customer hit after the resolver lift in #6247 moved Function blocks off source inlining. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…API (#6702) * fix(v2): close seven correctness and honesty gaps found sweeping the API A ten-slice sweep of the live v2 surface turned up no regression from the recent cancellation work, but did surface a set of pre-existing defects where an endpoint either lost data, hid a failure, or reported something that was not true. Each is fixed at the layer that owns the behavior. Terminal execution logs. The two force-fail boundaries wrote `status: 'failed'` without `ended_at` or `total_duration_ms`, so a force-failed run dropped out of every duration-filtered log query — the same defect class already closed for cancellation, still open on its sibling. The cancellation payload factory is generalized to take the status; the cancellation call sites are untouched and still emit a byte-identical row. Custom tools. One malformed row failed the whole page, and because the list is keyset-paginated that row made every page containing it permanently unreachable. The projection now validates against the same contract schema the route builder applies, repairing only what can be repaired without inventing information — a stringified schema, and a missing `type` whose contract admits exactly one value — and omitting with a warning what cannot. Both rows observed in production are recovered rather than discarded. Table filters. `eq`/`ne`/`in`/`nin` compiled a wrongly-typed operand into a containment test that silently matched nothing, so a filter written against the value the write path had stored returned an empty page instead of its rows. The operand is now read through the same column-type registry the write used, and rejected only where that registry refuses it. Range operators already behaved this way; `null` and the cleared-cell sentinel still pass through untouched. Error messages. A custom `error` on a string schema also replaced the wrong-type wording, so supplying a number for a name reported that the name was missing. Messages now distinguish an omitted field from a mistyped one, `topK` names its own bounds, the knowledge search refine reports against a field rather than the whole body, and a workspace id is bounded before it reaches a lookup. Archived file metadata. A soft-deleted file was listed but unreadable, leaving no way to check share state before restoring it. The read takes the same `scope` selector the list already exposes; the default is unchanged, and the parameter relaxes only the `deleted_at` predicate, never the authorization. Cancellation reporting. Cancelling an already-terminal run reported a durable write that never happened. The service now distinguishes the no-op and names the state it observed, and both surfaces present one vocabulary instead of the internal route deriving its own. No claim predicate or write changed. Protocol. A 401 carries a challenge naming the header the API actually reads, and a body that failed to parse is reported as an unsupported media type only when the caller positively declared a non-JSON one — after the read has already failed, so nothing that succeeds today can begin to fail. * fix(v2): correct three regressions this branch introduced, and harden its tests Adversarial review of the previous commit found that three of its "behavior preserving" claims were wrong. Each is corrected here at the layer that owns it. Table filters no longer coerce a `date` operand, and no longer throw. `date` is the one column type whose registry `coerce` is not idempotent — it drops sub-second precision — and the leaf that compiles a filter also builds the unique-constraint and upsert-conflict probes, so re-reading an already-coerced operand could stop it matching the row it was written from and admit a duplicate inside the write transaction with no error. Throwing was the second mistake: the v2 predicate grammar type-checks structure but not operand values, so a rejected operand no longer failed at submission but inside the delete, update, dispatch and cancel runners, where a filter that cannot compile means the cells it started can no longer be cancelled. Coercion is now total — it rewrites what the registry accepts and passes everything else through unchanged, exactly as before. Reviving a force-failed run no longer inherits its terminal duration. Writing `ended_at` and `total_duration_ms` on the force-fail boundary was correct in isolation, but a partial resume flips that row back to `pending` and those columns survived. The preserved value is meant to be the pause checkpoint — the run's active time — and it had become wall clock measured at the failed resume, which the checkpoint rule then faithfully carried into the next terminal write. The revival clears them only for a row that was terminal, so an ordinary paused row keeps the checkpoint it is supposed to keep. Cancelling reports the terminal state it actually observed. Reclassification now requires that nothing else went wrong, so a genuine paused-reconciliation failure survives instead of being rewritten as an already-terminal no-op, and the claim's own row count — not a snapshot read before it — decides whether this cancel terminalized the run or lost a race to something else. The status the snapshot needed rides along on the ownership query that already reads the row, rather than the second read that query's own contract warns against. A custom tool that cannot be projected now answers the same way everywhere: the list omits it, and reading or patching it by id reports it as absent rather than as a server fault. Analytics stops reporting a cancellation for a request that cancelled nothing. The tests around all of this were audited by mutating each fix and checking the suite noticed. Where it did not, the assertion is stronger now: the absent content-type branch is genuinely exercised rather than relying on a header the client library supplies, the duration encoder is pinned to the column it must measure from, execution ownership is pinned to both ids it must match, and the archived-file concealment test proves it conceals the archived read specifically. Two tests that asserted a paused branch they could not observe are gone; the rendered-SQL test that can decide it already covers them. * fix(execution): report a workflow-group cancellation as the write it performed Cancelling a workflow-group run whose log had already been cancelled, but whose cell sidecar still needed reconciliation, durably cancelled that sidecar and then reported `already_cancelled` with `durablyRecorded: false` — because the terminal-status shortcut answered from the entry snapshot alone and never asked what this request had written. The analytics event, which now gates on that field, stopped firing for a cancellation that really happened. The outcome a cancel reports is the same question whichever path answers it, so there is now one vocabulary for it rather than one the direct claim tracked and one the group transition did not. Every group result maps to that outcome through a total map, so a new group result cannot compile without deciding what it wrote, and the reclassification leads with whether this request wrote at all. A group transition that reports itself already cancelled is deliberately mapped as unknown rather than as a no-op: it leaves the sidecar alone but still terminalizes a log that was active, and the result does not say which happened. That costs nothing today, because the only snapshot that would reclassify proves the log was already terminal. * fix(execution): have a workflow-group cancellation report the writes it made Three review findings landed on the same reporting logic, each a different face of one cause: the caller could not see what the group transaction had written, so it inferred. It inferred from an entry snapshot, then from the returned kind, and the remaining blind spot was the kind that covers two different transactions — a repair that terminalizes an active log, and a genuine no-op — which left a cancel that wrote nothing still claiming a durable write when it lost a race. The transaction now reports both writes it can make, each read from that statement's own returning row and recorded immediately before the throw that already depended on it, so the report cannot drift from the write. The caller derives its outcome from those rather than from the kind, and the kind is back to naming the situation instead of standing in for the work. The group path can now always answer whether it wrote. The only remaining unknown is the direct claim when its update throws or is never attempted, which genuinely has no row count to report.
* feat(connectors): add 9 knowledge base connectors Box, Zoho Desk, PagerDuty, Trello, Microsoft Excel, Google Slides, Google Vault, Mintlify, and SFTP. Selected by intersecting the published connector catalogs of Glean, Onyx, Dust, Vectara, Writer, Guru, Elastic, Microsoft 365 Copilot, Notion AI, Unstructured, and Airbyte against services that already ship a Sim block, so OAuth providers, credentials, and icons are reused. Box was the largest gap, appearing in 7-8 of ~10 catalogs. Every connector was validated against live provider documentation twice, the second pass treating the first pass's conclusions as unproven. Notable correctness work that came out of that: Listing truncation. The sync engine hard-deletes documents past a cap that is not flagged with `listingCapped`, and five connectors had a path there — an empty Mintlify discovery, Zoho Desk's exact-multiple default caps, Trello's archived lists and 1000-card ceiling, a Google Vault cursor bailout, and a PagerDuty stalled page. The engine also gained a backstop: an empty or collapsed listing blocks deletion reconciliation until the same observation repeats on a consecutive sync, reconstructed from existing sync-log counters so no migration is needed. API alignment. `desk.zoho.ca` does not resolve (Canada is `desk.zohocloud.ca`, and Singapore and UAE were missing); `modifiedTime` is absent from Zoho's ticket list projection, so every ticket re-embedded on every sync; Trello's `dateLastActivity` is documented to miss some edits; PagerDuty's 10,000-record ceiling bounds `offset + limit`, not offset; Excel indexed dates as raw serial numbers while Google Sheets renders them; Google Vault truncated at roughly 249 matters. Security. SFTP followed symlinks in `getDocument` and composed unchecked server-supplied filenames into paths; it now also supports optional host-key fingerprint verification, which runs during key exchange before any password is sent. Trello interpolated user-supplied board ids into URL paths. Google Vault is narrowed to `ediscovery.readonly`. `getDataverseBaseUrl` accepted any host while attaching a bearer token, and is pinned to Microsoft's Dataverse domains — pre-existing shipped code, fixed here. Also adds `ConnectorAuthConfig.optional` so a public source can be configured without inventing an API key, and teaches the scope check that a granted read-write scope satisfies a required `.readonly` sibling. Microsoft Dataverse was built and then removed: its OAuth cannot complete consent. Dataverse requires a per-environment resource URI, the provider declares a static `https://dynamics.microsoft.com/user_impersonation` that is not an Entra Application ID URI, and the environment URL is only collected after the credential exists. That predates this change and also affects the 12 shipped Dataverse tools. * fix(dataverse): strip the bearer token when a request redirects The host allowlist added alongside the connector work only constrains the initial destination. `secureFetchWithPinnedIP` follows redirects and keeps the `Authorization` header unless a tool opts out, so a redirect away from an allowed Dataverse origin would forward the caller's OAuth token to whatever host answers. Dataverse redirects in normal operation — file downloads hand back a signed storage URL, and environment hosts move between regional origins — so this is reachable without a compromised environment URL. Sets `stripAuthOnRedirect` on all 18 Dataverse tools, matching the existing GitHub job-logs and Windchill precedent. * fix(connectors): address review findings on listing and hashing - microsoft-excel: `fetchWorksheets` read only the first Graph page and never followed `@odata.nextLink`. A workbook with more sheets than fit in one page dropped the remainder from the listing without setting `listingCapped`, so the sync engine reconciled those documents away as deleted. The walk now pages, bounded by MAX_WORKSHEETS, and only follows a nextLink that stays on the Graph origin, since the link is server-supplied and carries the token. - google-slides: the listing `contentHash` covered only the file id and modified time, so toggling the speaker-notes option left every stored hash matching and no presentation was ever re-hydrated with the new scope. The setting is now part of the hash, in the single shared stub builder so the list and hydrate paths stay identical. - mintlify: `pathPrefix` filtered with a bare `startsWith`, so a prefix of `/guides` also matched a sibling like `/guides-archive`. It now shares the `/`-boundary rule `withinBasePath` already used, extracted as `isUnderPath`. * fix(connectors): list newest first in zoho desk, accept a trailing slash prefix - zoho-desk: `sortBy: 'createdTime'` is ascending — Zoho denotes descending with a `-` prefix — so the default 500-record caps kept the oldest tickets and articles and recent ones were never listed. Because the cap sets listingCapped, that stale tail could not reconcile away either. Now sorts `-createdTime`. Still ordering on createdTime rather than modifiedTime, so rows do not reshuffle mid-walk. - mintlify: `resolvePathPrefix` kept a trailing slash while `isUnderPath` accepts an exact match or `prefix + '/'`, so `/guides/` matched neither `/guides` nor `/guides/intro` and the source synced nothing. A regression from the previous round, which replaced a bare `startsWith`. The prefix is now normalized before comparison. * fix(dataverse): strip the bearer token on the upload route's own redirect `upload_file` posts to an internal route rather than calling Dataverse directly, so the tool-level `stripAuthOnRedirect` added in 903c94e only covers the same-origin hop into that route. The route's own outbound PATCH carries the caller's OAuth token and left redirect stripping at its default, so a redirect to a signed storage host — which is exactly how Dataverse serves file operations — would have handed that host a reusable credential. The other 17 tools build the Dataverse URL directly, so the tool-level flag already covers them.
…ion warnings (#6706) * fix(logger, blocks): log nested errors and stop spurious model-selection warnings Two production defects found in the prod logs, neither release-related. logger: mergeArgs copied object arguments verbatim, so an Error held under a key stayed an Error instance and JSON.stringify rendered it {} — message and stack are non-enumerable on Error.prototype. 399 call sites use the logger.x('msg', { error }) shape and every one logged error: {}, which is why BlockOutputs failures (10k/week) were undiagnosable. The colorized path had the same hole via formatObject. blocks: router, evaluator and agent resolved config.tool through getBaseModelProviders(), which deliberately excludes gateway providers (OpenRouter, vLLM, LiteLLM, Ollama, ...). A valid openrouter/* model therefore threw "Invalid model selected", as did a model still holding an unresolved <variable.x> at serialization time — ~140 warnings/day. The value is cosmetic (every handler re-derives the provider from the resolved model), so the throw bought nothing. - unwrap keyed Errors on both the structured and colorized log paths - keep `error` a plain message string so log queries can group on it - resolve serialized provider ids through getProviderFromModel, the same resolver the executor uses, via one shared helper for all four call sites - drop the unreachable `if (!model)` checks behind `params.model || default` * fix(blocks): move the fallback rationale into TSDoc The repo forbids non-TSDoc comments; the explanation for why recovery returns a constant instead of resolving again belongs on the declaration anyway.
…on lifecycle ops (#6703) * feat(tools): add incremental job sync and draft postings to Ashby reads list_jobs accepts Ashby's syncToken and returns it as nextSyncCursor, so a scheduled sync costs O(changed reqs) instead of rescanning every req. Ashby only returns the token once the last page is drained, which the param description states. The output is named as a cursor deliberately. It is an opaque resumption marker, not a credential, so it belongs with nextCursor - and a field literally named syncToken matches the /^.*token$/i deny-list in redaction and renders as [REDACTED], which makes an incremental sync unusable since the operator cannot read the value the next run needs. The wire name stays syncToken. list_job_postings gains includeUnpublishedJobPostings, plus the posting status field - without status a caller cannot tell a returned draft from a published posting, which makes the flag useless. Also widens the custom field valueLabel type, which MultiValueSelect returns as an array, for the write operations that follow. * fix(tools): render Ashby object-shaped API errors readably Ashby documents two error shapes and uses both. The `errors` array form carries `{ message, parameter }` objects, which stringified to '[object Object]' and hid the real cause - including the 403 a key gets when it lacks a module permission. Also adds the shared pieces the new write operations need: one definition of the custom field value shape for the read and write paths to agree on, and a normalizer for Ashby's case-sensitive objectType enum so a model emitting 'candidate' fails here with the allowed values rather than at the API. * feat(tools): add Ashby custom field writes, delete, source, and anonymize customField.setValue/setValues are the only way to annotate a job or req, since Ashby has no job notes and no job tags. Writing null clears a value, so the annotation is reversible. Because null clears, every one of these operations requires explicit intent before it can destroy data. The block's required markers do not cover the agent path - a model calls the tool directly, so tools.config.params never runs and validateRequiredParametersAfterMerge skips a param marked not-required: - set_custom_field_value rejects an absent or blank fieldValue; an explicit null still clears - change_application_source requires unsetSource to clear, and rejects a source id and an unset request together, since preferring either one silently discards the other. Ashby has no 'leave unchanged' mode, so setting and clearing are the only two intents and exactly one must be expressed - set_custom_field_values rejects an empty array locally rather than relying on Ashby to reject it application.delete needs candidatesDelete, a module permission separate from candidatesWrite. candidate.anonymize strips PII but leaves the record; Ashby exposes no candidate deletion endpoint. * test(tools): cover the new Ashby request and response shapes Includes a gated live harness (ASHBY_LIVE=1) alongside the mocked tests. vitest.setup.ts stubs global fetch for every file in the app, so the live file restores the real implementation and asserts the restore worked - without that guard the whole suite silently passes against a mock. * feat(blocks): expose the new Ashby operations in the block fieldValue is polymorphic (boolean, number, string, array, object, null), so it decodes structured input and otherwise passes text through. The decoding is deliberately narrow rather than a blanket JSON.parse, which corrupts real text: 1e999 becomes Infinity and serializes back out as null, which CLEARS the field; a long numeric id loses precision past 2^53; and prose starting with { turns into an object. Only the literal keywords, {, [ or " prefixes, and exactly round-tripping numbers decode. fieldValue carries no wand generationType: json-object forces braces and json-array forces brackets, and both would wrap a value that must stay bare. fieldValues, whose contract really is an array, uses json-array. Setting and clearing an application source are mutually exclusive, so the Source ID field is conditioned off while the clear switch is on and the params mapping sends only the intent the switch selects. A value typed before the switch was flipped cannot reach the tool and surface as an error with no visible cause. * docs(ashby): document the new operations, permissions, and limitations Ashby scopes permissions per module and they fail at runtime, not build time, so the block docs now carry the permission table. Also records the hard API limits worth designing around: no note or tag on a job, no pagination on jobPosting.list, and no delete for jobs, candidates, or custom field definitions. * fix(blocks): stop a stale create-path source id leaking into a source change The executor merges { ...inputs, ...transformedParams }, so any key the params mapping leaves unset inherits whatever inputs held. The shared create-path sourceId subblock reaches inputs even on change_application_source: it is mode 'advanced', and the serializer includes an advanced subblock whenever its value is non-empty without ever evaluating its condition (serializer/index.ts). So a source id typed while on Create Application survived into a source change. With both fields blank it silently attributed a source nobody asked for, and with the clear switch on it collided with the unset request and failed with no visible cause, because the field producing it is hidden in that state. sourceId is now always assigned for this operation rather than conditionally, so it can never inherit. The regression test asserts the merged result rather than the mapping alone, since the gap between them is where the bug lived.
* fix(integrations): read every service mark from one registry A service looked like itself on the canvas and like nothing in particular everywhere it was connected. `OAUTH_PROVIDERS` registers 93 icons and no colour at all, so the surfaces built on it — the connect dialog above all — drew a flat grey mark for a block whose config already carries its brand icon and `bgColor`. Bridges the two: `resolveIntegrationBlockTypeForOAuth` maps any OAuth id (a service id, a provider id, an extra authorization server) to the catalog block behind it, so a credential surface holding only an OAuth identity can still reach the registry. The connect dialog now wears the block's tile, and `ChipModalHeader` takes a rendered mark so a tile can carry its own chrome instead of being tinted with the header's grey. Folds in the copies that had grown around the gap: `IntegrationTile` resolved its fill from the registry but took its icon from whatever the caller passed — one tile, two sources — and now defaults to the registry, with an override kept for the family service-account marks that genuinely are not the block's. The letter fallback it grew alongside was reading the catalog's `bgColor` while the tile beside it read the registry's; both are the tile now. Two `getProviderIcon` implementations for the same job (one tinted, one not) become one `ProviderIcon`, the connector tile duplicated verbatim across two knowledge-base surfaces becomes one `ConnectorTile`, and the permission rows that hardcoded `text-white` — which renders white-on-white on a pale brand tile — go through `BlockTile`. Public pages keep their generated catalog: importing the registry there would ship 282 block configs to a marketing page, and `integrations.json` is generated from the same `bgColor`, so the two cannot drift. * fix(blocks): drop the dead copies of a block's colour Sweeping the surfaces above turned up colour data nothing reads and colour data two surfaces disagreed on. Dead: `BLOCK_COLORS.DEFAULT/LOOP/PARALLEL` in the tag dropdown (only `VARIABLE` was ever referenced), `BlockIconInfo.color` on table columns — whose consumer documents that it deliberately ignores the colour, so the `#2F55FF` behind it could never render — and the `bgColor` threaded into the add-resource dropdown, whose row renders a bare tinted icon. Disagreeing: the Variables tile is `#2F8BFF` in the tag dropdown and `#8B5CF6` in the preview panel, for the same "V" on the same concept. Both now read `VARIABLE_TILE_COLOR`, and the preview panel's two hand-rolled squares become `BlockTile` like every other tile. Four spellings of the neutral fallback (`#6B7280`, `#6b7280`, `#666666`, and a `cancelled` status that happened to equal it) now point at `DEFAULT_BLOCK_TILE_COLOR`. The terminal and logs resolvers stay. They look like duplicates of `accent.ts` but carry behaviour it does not have — status fills for synthesized error/validation/cancelled rows, near-black contrast correction, MCP tool-id parsing, and a model-provider branch — so folding them in is a behavioural change, not a deletion. * chore(copilot): delete the mention machinery nothing calls The workflow panel's copilot tab renders `MothershipChat`, and that component brings its own input — so `panel/components/copilot` no longer holds a component at all, only the hook library the old input used. Five of those hooks have no caller anywhere: `useMentionData`, `useMentionKeyboard`, `useCaretViewport`, `useMentionInsertHandlers`, `useTextareaAutoResize`. They are not all of it. `home/components/user-input` still imports `useFileAttachments`, `useMentionMenu`, `useMentionTokens`, `useContextManagement`, and `useIntegrationAutoMention` from this directory, so it survives as a shared hook library rather than dead weight — which is why this removes the uncalled five rather than the folder. What they alone reached goes with them: `getFolderData` / `getFolderLoading` / `getFolderEnsureLoaded` and the `FOLDER_CONFIGS` table describing every mention folder, `buildMentionHighlightNodes`, the `MentionFolderNav` type, and the slash-command tables. Of the 266-line constants file only `SCROLL_TOLERANCE` had a live reader left. * fix(integrations): never answer with a sibling's tile Two integrations can share one OAuth id: Google Slides is authenticated by Drive's `google-drive` service and Jira Service Management by Jira's `jira`. Indexing first-write-wins made those ids resolve to whichever sorted first, so the dialog connecting Slides could wear Drive's brand. An id claimed by more than one block type now resolves to neither, and the caller keeps the service-specific mark it already had. A wrong brand is worse than no tile.
…mcn family (#6708) The table/table_v2 blocks and the table trigger used a local lucide-shaped TableIcon (stroke 2.0, full-bleed 24 viewBox, 3x3 grid) while every other table surface used emcn's Table. Consolidate onto the emcn icon and drop the local copy. Nested tool-call rows in Chat applied no color class, so a non-brand block icon inherited body text instead of --text-icon. redo/undo/zoom-in/zoom-out draw 0.85 stroke on a 12-unit viewBox, rendering 0.992px at a 14px box against the family's 0.904px. 0.775 restores parity.
… and contact-point CRUD (#6712) * fix(azure-data-explorer): correct the tags ingestion-property example The example rendered as tags="[''daily'']" — doubled single quotes from an escaping slip, which is not valid Kusto. The reference writes a tags list as tags='["TagA","TagB"]': single outer quotes with the JSON array's own double quotes inside. The clause builder already handled that form; only the example text was wrong. A template literal avoids the escaping entirely, since the metadata generator reads the source verbatim and would otherwise carry the backslashes into the description the model sees. Adds a test asserting the reference's exact multi-property clause round-trips, including the comma inside the quoted array. * fix(grafana): correct response contracts, required alert fields, and outbound request hardening Validated against Grafana's HTTP API reference and, where the docs contradict themselves, against the Go wire structs. Response shapes the tools got wrong: - update_annotation declared an `id` that was always 0; a patch returns only a message, so the request's annotation id is echoed and labelled as such - delete_folder discarded the numeric id Grafana returns and presented an input-echoed uid as if it came from the API - delete_dashboard fabricated `id: 0` / `title: ''` via `||` on absent fields - the contact-point `provenance` description was inverted: "api" means API-managed, empty means it stayed UI-editable Requests that could not succeed: - create_alert_rule left noDataState and execErrState unset and invisible to the model, but Grafana's validator rejects an empty value outright, so every model-driven create failed. Both are now sent with Grafana's own defaults, and skipped for recording rules, which take a different validator - get_data_source routed a numeric input at /api/datasources/:id, which exists only behind an off-by-default feature toggle. UID only now - list_annotations did not trim the dashboard UID, so a padded value matched nothing Outbound hardening on the three proxy routes: - the service-account token was re-sent to redirect targets; the shared fetch only drops it when asked, so stripAuthOnRedirect is now set - no timeout was passed, leaving two sequential hops at the 5-minute default - upstream error bodies were interpolated whole into the tool result, putting up to 10MB of HTML into logs and traces; now truncated - UID path segments are URL-encoded so they cannot re-target the request - update_folder sent both `version` and `overwrite: true`, which Grafana treats as alternatives, making the freshly fetched version decorative and silently clobbering a concurrent rename - replaced the `any` casts with narrowed types Block surface: - 25 outputs the tools emit were undeclared and so unreferenceable downstream; get_data_source had 13 of its 18 unreachable - `version` was typed string though the dashboard, folder, and data-source producers all emit a number - the dashboard title field was shown only for create, so a dashboard could never be renamed through Update Dashboard - six list outputs were typed json rather than array * fix(grafana): let the health check report ill-health, and disambiguate block outputs The data source health check could only ever report health. Grafana answers an unhealthy source with HTTP 400 carrying the same {status, message} payload as a healthy one, and the tool framework converts any non-2xx into an opaque tool error — so the diagnostic the caller actually wants was unreachable. The check now goes through an internal route that reads the verdict off either status and reports it as a successful check, while a failure carrying no verdict (missing data source, bad token, plugin with no health endpoint) stays a real error. The plugin's `details` payload is surfaced too. Also on that route, matching the other three: an outbound timeout, redirect auth stripping, a truncated upstream error, and a URL-encoded UID. Block output descriptions: ten keys are emitted by several tools with different meanings and were described for only one producer — `database` meant both a data source name and a health status, `annotations` both an annotation list and an alert rule's summary map. Eleven `json` outputs were opaque although the tools already document their inner fields. All rewritten to name every producer. Smaller alignment fixes: - the same EmbeddedContactPoint.settings field was typed `object` in list and `json` in create - list_contact_points mapped non-nullable uid/name/type through `?? null`; Grafana returns an empty string, which is what create already assumed - create_alert_rule sent `orgID`, which Grafana overwrites from the authenticated context, and `Number()` on a non-numeric value put NaN -> null in the body - the three update routes declared `output` as required though the auth short-circuit omits it, and did not declare the `details` they emit on a validation error * feat(grafana): complete contact-point CRUD, and add folder move and rule-group read Four operations the integration was missing, taking it to 29. update_contact_point / delete_contact_point close a real gap: contact points could be listed and created but never corrected or removed. Two things worth recording, because the published docs get both wrong: - both verbs answer 202 with only a message, not the object. The rendered docs claim delete returns 204; the current spec and handler both say 202. So the UID is echoed from the request, the way delete_folder and update_annotation already do - update is a full replace with no PATCH counterpart, so name, type, and settings are all required and the description says so. Omitting disableResolveMessage resets it X-Disable-Provenance is exposed on update only. Its polarity is the opposite of the alert-rule case: omitting it always succeeds, while sending it against an API-provisioned contact point is rejected — with 403, not the 409 rules use. It is not exposed on delete at all, because that handler never reads stored provenance and the endpoint takes no such parameter. move_folder reuses get_folder's mapping verbatim — same DTO. It always sends the parentUid key, since Grafana reads an empty value as "move to the root", which a conditionally-omitted field could not express. get_alert_rule_group surfaces the group evaluation interval, the one alerting knob the per-rule operations cannot reach. It reuses the shared mapAlertRule for the nested rules, and the interval is documented as an integer of seconds. * feat(grafana): add data source querying, and ground the skill and templates in real tools query_data_source closes the largest gap in the integration: 29 tools could read dashboards, folders, and alert configuration, but none could read a metric value. It posts to /api/ds/query and returns both the raw response and the frames flattened into rows. The flattening is derived from the documented layout rather than any data source's field names: a frame carries schema.fields[] alongside data.values[], where values[i] is the whole column for fields[i], so zipping them by position works for Prometheus, SQL, or anything else with a backend. A failed query is a 400 by Grafana's own status table, so it stays a tool error — unlike the health check, where the failure status carries the answer. That also lets four templates and the review-firing-alerts skill stop promising things the integration could not do. Three templates assumed a metric-query tool, which now exists. The fourth, and the skill, assumed live alert instance state, which the provisioning API never returns — they now derive firing rules from alert-state annotations, which are documented to carry newState and prevState, and say so explicitly rather than implying a live snapshot. Deliberately not added: a tool over /api/prometheus/grafana/api/v1/rules for live instance state. That endpoint appears on no Grafana HTTP API doc page, its response is only readable from Go internals and test assertions, and the instance-level state casing differs from the rule level with no documented contract. Not something to build an output schema on. * fix(grafana): declare the two block outputs the earlier fixes introduced Renaming update_annotation's phantom `id` to `annotationId` and adding `details` to the health check both created outputs the block never declared, so neither was referenceable downstream. Caught by re-running the output-coverage check over both integrations; the block now covers all 64 keys the 30 tools emit. * fix(grafana): make Update Contact Point actually usable from the block The new replace operation could never succeed. contactPointType and contactPointSettings were widened to cover it, but contactPointNameNew was left create-only — and the update maps `name` from that field, so the required parameter was never supplied. disableResolveMessage had the same gap, and it matters more than it looks: the update is a full replace, so a block-driven update was silently clearing resolve suppression on every contact point it touched. Both fields are now shown, and required where the API requires them. Also states a reason on each intentionally-unconstrained response field — Zod issue objects, alert query stages, notification settings, recording-rule config, and data-source health detail are all genuinely opaque, but that was left implicit.
* refactor: consolidate local isRecord guards onto shared isRecordLike Nineteen files had re-declared a local `isRecord` guard rather than using the shared one from `@sim/utils/object`, drift that reappeared after #5061 first consolidated them. Two more imported the shared guard under an `isRecordLike as isRecord` alias. The copies were not interchangeable. Nine matched `isRecordLike` exactly. The rest omitted the array exclusion (`typeof x === 'object' && x !== null`, or `Boolean(x) && typeof x === 'object'`), so arrays passed the guard. Each of those call sites was reviewed individually: in every case the guard is followed by string/number field checks that an array fails anyway, so the outcome is unchanged. The one exception is `isOptionsTagData`, where `Object.values` on an array of option items really did make an array-form `<options>` tag render. It now accepts arrays explicitly rather than by accident. `executor/handlers/pi/search/extension-source.ts` keeps its own copy: it is source text written into an E2B/Daytona sandbox at runtime and cannot import. * refactor: replace inline record guards with isRecordLike 103 inline `typeof x === 'object' && x !== null && !Array.isArray(x)` guards (and the `x &&` / `Boolean(x)` spellings of the same conjunction) now call the shared guard. Inside a conjunction that already asserts `typeof x === 'object'`, `x &&` and `x !== null` are interchangeable, so all three orderings are the same predicate at runtime. Only exactly-equivalent conjunctions were converted. Matching required the three clauses to be one adjacent conjunction over the same operand, so a nearby but unrelated clause cannot be absorbed — generic-handler.ts, where the array case is handled inside the block rather than excluded by the guard, is correctly left alone. Two sites were reverted after type-check rejected them: instagram/server-utils.ts and workflows/[id]/log/route.ts both cast straight to a specific interface, which is legal from `object` but not from `Record<string, unknown>`. Their narrowing is genuinely not identical, so they keep the inline form rather than acquiring a double cast. Left as-is: `packages/ts-sdk` (published with no runtime dependencies) and the two sandbox sources written into E2B/Daytona as text, which cannot import. * refactor: consolidate duplicate record coercion helpers onto @sim/utils A second sweep found 28 more local record helpers hiding under names the previous `isRecord` grep never matched — `asRecord`, `toRecord`, `toRecordOrNull`, `asObject`, `isJsonObject`. rabbitmq defined the same `asRecord` twice within one service; dynatrace had three variants in one file. Fourteen of them were re-deriving the same two shapes, so those shapes now live in `@sim/utils/object` beside the guards they wrap: toRecord(value) // isRecordLike(value) ? value : {} toRecordOrNull(value) // isRecordLike(value) ? value : null Both preserve identity on a hit, so no call site starts copying. Eighteen local definitions are gone. `tools/instantly/utils.ts` keeps its exported `asRecord` because its return type is the local `JsonRecord` alias, but its body now delegates. `app/api/mcp/serve/[serverId]/route.ts` had an `isJsonObject` with zero call sites — deleted outright. Six helpers were deliberately left alone because they are NOT equivalent: `pagerduty`/`zendesk`/`gitlab` do `(value as Record) || {}`, which type-checks nothing at all, and `copilot/resources/extraction.ts`, `edit-workflow/validation.ts`, `pi/core/events.ts` omit the array exclusion. Those sit on webhook ingress and copilot paths where tightening is a behavior change, not a cleanup; they are audited separately. Two guards were removed rather than substituted, each proven dominated by an earlier check: the bedrock streaming `toolUse.input` guard was unreachable (`parseToolInput` already throws on non-objects before the loop builds `assembledToolUses`), and four `driver.ts` re-narrows follow an `if (!isRecordLike(x) || ...) throw` that dominates the later use. * fix(webhooks): guard non-string GitLab ref, and consolidate the last record helpers Two audits covered the six helpers held back from the previous commit for not being equivalent to isRecordLike. Five are now migrated; one is deliberately not. `gitlab.ts` carried a real crash path, independent of the guard work: const ref = (b.ref as string) || '' const branch = ref.replace('refs/heads/', '') The cast is unchecked and `|| ''` only catches falsy values, so a truthy non-string body field — `{"ref": 12345}` — reaches `.replace` and throws `TypeError: ref.replace is not a function` inside `formatInput`. That runs in the background worker after the webhook is already 200-ACKed, and GitLab does not auto-retry, so the delivery is lost silently. Now checks `typeof`. `pagerduty`/`zendesk`/`gitlab` each defined `asRecord` as `(value as Record<string, unknown>) || {}`, which type-checks nothing — a string or array passed through and was then spread into the workflow trigger payload (`gitlab.ts:114`) as character- or index-keyed garbage. All three now use the shared `toRecord`. These sit behind `verifyProviderAuth`, so reaching them requires the shared secret; this is robustness, not authorization. `copilot/resources/extraction.ts` and `pi/core/events.ts` were array-permissive but provably inert — extraction.ts has no key enumeration or spread anywhere, and events.ts only diverges by returning `null` instead of `{type:'other'}` for an array, which every consumer already no-ops on. Pinned with a test. `edit-workflow/validation.ts` is left permissive ON PURPOSE. Its `Object.entries` walk mirrors the unguarded walk in `operations.ts:86,191`, so an array-shaped `nestedNodes` from the model is currently visited by both. Tightening only the validation side would stop `collectHostedApiKeyInput` from stripping platform-managed API keys while the apply side still creates those child blocks. Both paths have to change together, with tests, in their own PR. * refactor: delete 31 unreachable module-local functions Removes 815 lines of provably dead code: module-local (non-exported) declarations whose identifier appears exactly once in their own file — the declaration itself. A non-exported symbol cannot be reached by an import, a barrel, a dynamic import, or a framework convention, so "unreferenced in its own file" is a complete proof of deadness rather than a heuristic. Notable removals include whole abandoned code paths: `findWebhookAndWorkflow` (78 lines), `calculateBillingProjection` and `initializeUserUsageLimit` (80 lines), `removeCredits` + `deductFromCredits` (54), `sendBatchSMS`, `executeToolBatch`, and four unused `async-runs/repository.ts` queries. Spans come from the TypeScript AST, not a regex. A regex cannot find a declaration's extent — a first-brace scan cuts inside a return-type annotation such as `Promise<{ canCreate: boolean }>` and silently corrupts the file. The AST pass also re-derives deadness from identifier nodes, which caught two wealthbox helpers that a regex export-check had wrongly reported as local. Note the runtime TypeScript API here is `@typescript/typescript6`; the bare `typescript` specifier resolves to the native compiler, which exposes no `createSourceFile`. * refactor: delete 434 stranded exported symbols Removes ~5,930 lines of unreachable code across 166 files: exported symbols that no other file in the repo mentions and that are unused inside their own file. Whole abandoned surfaces go with them — unused React Query hooks (useOrganizations, useOrganizationMembers, useUpgradeSubscription, ...), unused admin route contracts, dead executor constants and reference builders, and the landing-page StageWorkflow/LandingPreviewMount components. Three files left with no remaining code were removed outright. Candidates came from an AST pass; each was then verified individually against an UNFILTERED repo-wide search plus the reachability paths a name search misses: string-keyed tool/block registries, dynamic imports, `export *` barrels, and the docs generator's source-text parsing of tool files. 29 candidates were verified LIVE and kept. Those exposed a flaw in the candidate generator: it indexed only .ts/.tsx, while apps/docs/content/**/*.mdx imports React components directly — ActionImage appears in ~190 MDX pages and ActionVideo in ~115, and both scanned as dead. `app/global-error.tsx` was likewise kept, since Next.js reaches it by filename and its default export can never have a name reference. All 434 deleted names were afterwards cross-checked against every .mdx/.md/.json/.yaml in the repo: no hits. Verified with turbo type-check (23 workspaces), the full apps/sim suite (25,219 tests), all 26 audits, and a production `next build` — the last of these being what actually exercises route- and component-level reachability.
… fails (#6711) * fix(webhooks): read every Ashby error shape when webhook registration fails The provider read errorInfo.message and a top-level message, but not the `errors` array. Ashby uses three shapes in practice, confirmed live: objects `[{ message, parameter }]`, plain strings `['webhook_not_found']`, and `errorInfo`. A missing apiKeysWrite permission arrives in the array form, so the user saw 'Unknown Ashby API error' instead of the cause. The duplicate-webhook branch made it worse: it only fires when the message was extracted, so an unparsed error also cost the user the one actionable instruction for fixing it - delete the duplicate under Settings > API/Webhooks. Uses the shared ashbyErrorMessage extractor rather than a second partial copy, matching how other providers already import from @/tools. The delete path now reports why it failed instead of only the HTTP status. * style(webhooks): format the Ashby provider test to biome's apps/sim config CI runs `biome check .` from apps/sim; I had run biome ad hoc from the repo root, which resolves a different config and left this hunk unformatted. * fix(webhooks): keep the Ashby error extractor local to the provider Importing the shared extractor from @/tools/ashby/utils failed check:tool-registry-boundary. Two separate reasons, both real: An import edge from lib/webhooks/providers into @/tools/** grows the workspace page graphs that reach the providers, because @/tools/types statically reaches @/lib/oauth, the rate limiter and the executor. And carving the helper into its own file did not help either: the knowledge page graph already sits exactly at the +42 ceiling the audit allows, so one more module anywhere it can reach is one too many. So the logic is duplicated across the subsystem boundary rather than shared across it, with a comment on both sides saying why. Both copies derive from the same three documented Ashby error shapes and are covered independently. * fix(webhooks): fail an Ashby webhook delete that returns success:false Ashby returns what would be a 4XX elsewhere as HTTP 200 with `success: false` — its own docs state this explicitly. `deleteSubscription` branched on `ashbyResponse.ok`, so every rejected delete logged "Successfully deleted Ashby webhook subscription <id>" and never threw in strict mode. Sim then dropped its own row while the subscription stayed live in Ashby, and since there is no `webhook.list` endpoint the orphan cannot be enumerated afterwards. Check `success` the way `createSubscription` already does, and treat `webhook_not_found` as already-removed rather than an error — that is the shape an unknown id comes back in, not a 404. An absent `success` field stays a success here, unlike on create: teardown runs on the undeploy path, and failing closed on an undocumented response shape would wedge cleanup. Also corrects two trigger-surface details against the API reference: the setup text said the webhook is created when you save the trigger (it is created on deploy), and the jobCreate `employmentType` description omitted the documented `Temporary` value. * fix(webhooks): match Ashby's real not-found envelope on repeat delete The already-removed branch tested `/webhook_not_found/` against the extracted message, but `ashbyErrorMessage` returns `errorInfo.message` first and that reads "Webhook not found" — Ashby carries the machine code on `errorInfo.code` and in the deprecated `errors` array, both of which lose to the message. So the one envelope this branch exists for, a repeat delete of an id Ashby has already dropped, fell through to the failure path: a spurious warn today and a strict-mode throw on the undeploy cleanup path. Read the codes directly and keep a prose fallback for the message-only form. Caught by Cursor Bugbot. --------- Co-authored-by: Waleed Latif <walif6@gmail.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Too many files changed for review (548 files, 100 file limit). |
PR SummaryMedium Risk Overview Desktop/main code is tightened by routing untyped page/state payloads through shared OpenAPI billing schemas add Reviewed by Cursor Bugbot for commit daff022. Configure here. |
* fix(tables): allow unbounded v1 row queries * fix(tables): drain under-budget queries fully * fix(tables): bound expanded query metadata * fix(tables): always return query totals
…column menu (#6719) * feat(tables): filter by cell value from the cell menu, sort from the column menu * fix(tables): drop nested same-column conditions when filtering by cell value * fix(tables): refuse cell-value filters on json columns before the array branch
* feat(credentials): add managed credential groups * fix(audit): sync credential group mock * fix(credentials): serialize enrollment revocation * fix(credentials): isolate managed delegation * fix(credentials): serialize invitation lifecycle * fix(credentials): preserve enrollment lifecycle * refactor(credentials): migrate groups to application boundary * fix(credentials): serialize enrollment readiness * fix(credentials): preserve completed reconnect state * fix(credentials): revalidate policy before grant persistence * fix(credentials): prioritize expired invitations * fix(credentials): redirect unavailable oauth starts * fix(credentials): clarify managed oauth boundaries * fix(settings): complete feature flag test mocks * fix(credentials): clarify enrollment actions and entitlement errors * fix(credentials): preserve entitlement failure reasons * fix(credentials): refine managed oauth flow * fix(lint): use optional chain for pagination
…the permission group allows (#6720) * fix(access-control): default every operation picker to one the group allows The block editor's operation dropdown already hid operations whose tool the caller's permission group denies, but it seeded its default without waiting for that config. `usePermissionConfig` resolves as "nothing denied" while its query is in flight, so a freshly dropped block persisted the static first operation — and nothing revisits a field that already holds a value, so the correction that arrived with the config never applied. A user whose group denies `slack_message` still got a Slack block sitting on Send Message. The model combobox already had this guard; the dropdown did not. Consolidates the rule behind `lib/permission-groups/operation-access` and `useOperationAccess`, which resolves an operation to its tool without guessing (an unresolvable one stays visible; the server gate stays authoritative) and withholds a default until the config has loaded, so seeding on a defined value is the whole guard. Applies it to every surface that offers or seeds an operation: - block editor dropdown — default now waits for the config - agent block tool list — operations were not gated at all; the picker now hides denied ones, drops blocks whose every operation is denied, and defaults to the first allowed - canvas search / connection picker — the tool-operation index was filtered only by the block allowlist, so denied operations were still offered as one-click block drops - block creation — a declared default operation the group denies is replaced with the first allowed one, and a denied preset operation is discarded * fix(access-control): gate the seeded model too, and collapse the seed rule Block creation seeds `model` the same way it seeds `operation`: `agent`, `router` and `evaluator` all declare `defaultValue: 'claude-sonnet-5'`, and `prepareBlockState` wrote it unconditionally. The model combobox only fills a field that is empty, so a group denying that model (or the Anthropic provider) got an Agent block pre-filled with a model it cannot run — the same bug as the operation one, on the other axis the permission group governs. Rather than a second bespoke gate, `prepareBlockState` now takes one veto, `isSeededValueAllowed(subBlockId, value)`, and seeds nothing when a declared default is denied. Nothing substitutes a replacement there any more: the editor's own permission-aware pickers already resolve the right one and only fill an empty field, and substituting in the store would drift from `getDefaultBlockName`, which names a block after its *declared* default. That also deletes `firstAllowedOperation` and its copy of subblock-option enumeration. Review follow-ups: - `usePermissionConfig` gains `isModelUsable` (denylist AND provider allowlist); the combobox's two hand-rolled copies of that pair now call it - `isToolAllowed`/`isModelAllowed` index their denylists — the gate calls them once per option of every block offered, so a linear scan made a check's cost scale with denylist length (measured 3.2ms -> 0.30ms per search-index build at 500 denied tools) - `useOperationAccess` had three members with three different loading semantics, one documented as unsafe alone; it now exposes one withholding `resolveOperationGate` - the agent tool picker derived its option list twice with the empty-id filter on only one path; both callers now share one `{ options, denied }` result - `OPERATION_SUBBLOCK_ID` was a verbatim copy of the private constant in `canvas-sentence.ts`, doc comment included; that file now imports it * chore: trim restating comments and a dead coalesce Cleanup-pass findings on this branch's own lines: comments that restated the code they sat on, a four-line note whose sibling said the same in one, and a `?? undefined` on a non-nullable value. The comment on the tool-picker filter now explains the clause that actually needed it (an empty option list is not a denied one) instead of narrating the filter. Left alone as pre-existing and out of scope: the inline `staleTime` literal in `useAllowedIntegrationsFromEnv`, and the Operation selector's raw label / plain `Combobox` — both byte-identical to staging and matching the convention of every sibling field in that panel. * fix(access-control): seed nothing restricted when the config is unknown Block creation is one-shot, so the withholding pattern the editor's pickers use does not transfer: withholding the predicate there meant `prepareBlockState` seeded the declared `operation`/`model` defaults unchecked, and nothing revisits a field that already holds a value — so a block added before the permission config resolved kept a model the group may deny. Both restricted fields now seed empty until the config is known; the pickers fill them the moment it resolves. A preset operation is still honoured in that window: unlike a declared default it is the user's explicit pick, and the server gates the run. * fix(access-control): make the loading rule structural, not a convention Two review bots found the same class of bug in two more places, which is the real finding: "never persist from a predicate that reads as unrestricted while the config loads" was a rule each callsite re-implemented, and the rule had already been forgotten twice. Closes both reported instances and moves the rule somewhere it cannot be forgotten again: - `useOperationAccess.resolveSeedGate` now owns the creation-time veto for both restricted fields, so `workflow.tsx` states no policy of its own — it asks for a gate and passes it on. Previously the model half of the invariant was carried by an operation-shaped object that merely happened to be absent during the same window. - The agent tool picker and both operation selectors close while the config is unknown. Every list they offer — blocks, operations, MCP and custom tools — reads as unrestricted for that beat, and each pick is a one-shot write. - A preset operation goes through the same gate as a declared default. It comes from the search index, which is itself unfiltered while loading, so it is not the informed pick it looks like. - `isPermissionLoading` is exposed from one hook, so all four surfaces read the same symbol instead of four spellings of the same condition. Also from the review passes: dropped `isModelAllowed`/`isProviderAllowed` from the public interface (consolidating onto `isModelUsable` left them with no external consumer), un-exported `resolveOperationToolId` (no non-test caller), and corrected the `isSeededValueAllowed` TSDoc, which still described the contract the previous commit replaced. Tests: replaced a case that asserted its own fixture rather than the code with coverage of the two guard branches that were genuinely untested — an empty-string and a non-string declared default must bypass the gate, since both mean "nothing was declared" rather than a value to authorize. * fix(access-control): only gate a model field the provider allowlist is about The seed gate ran `isModelUsable` on every subblock named `model`, but `getProviderFromModel` resolves chat models and falls back to `ollama` for everything else. 28 of the 44 seeded model defaults in the registry are embedding, speech, image, video or search ids — so for any group with a provider allowlist that omits Ollama, those blocks were created with an empty model. Adds `findProviderFromModel`, the non-guessing half of `getProviderFromModel`, which returns `null` where the registry declares nothing. `isModelUsable` now treats an unresolved id as not-a-provider-choice and leaves it alone, matching the rule the operation gate already follows: never guess, and let the server stay authoritative. `getProviderFromModel` delegates to it, so there is one resolution path and its ollama fallback is unchanged. This also repairs the same misjudgement where it predates the branch: the model combobox filtered its options through the identical provider check, so those 28 defaults were already being hidden from their own pickers for allowlisted groups. The dead `try/catch` around the old call went with it — `getProviderFromModel` returns a fallback rather than throwing for an unknown id.
Uh oh!
There was an error while loading. Please reload this page.