Commit Graph
4710 Commits
Author SHA1 Message Date
Waleed 3ff91f0439 improvement(docs): clean up leftovers from the code-block alignment PR (#6825)
* improvement(docs): clear leftovers from the reverted revisions

A cleanup pass over the final state. Every finding was residue from an approach
this PR tried and abandoned, or a claim that stopped being true when it did.

- Delete the copy-button svg sizing rule: a later rule sets `display: none` on
  that same element ungated, so sizing it was never observable. Superseded by
  the mask approach.
- Drop the paragraph in page.tsx arguing about a custom Shiki factory. The
  factory was deleted; nothing configures one now.
- Correct shiki-curl-json.ts, which still claimed the grammar "reaches the
  client path too". It does not — that was the justification for choosing a
  grammar over a transformer, so leaving it stated the opposite of the truth.
  Now records where it applies, where it does not, and why not to retry.
- Correct the global.css section header, which claimed the component owns the
  shell while the next rule defines it here.
- Qualify the `--copy-glyph` declarations with `:has(> svg[class*="lucide"])`,
  which the group's own comment asserts of every rule in it.
- Correct `getCode`'s TSDoc: the gutter is a `::before`, and pseudo-element
  content never reaches `textContent`, so line numbers were never what the
  clone guards. It guards transformer-emitted `.nd-copy-ignore` nodes.
- Compose `chipGeometryClass` and emcn's `ChipChevronDown` in the API example
  selector instead of restating their literals.
- Merge the duplicated `div[role="region"]` rule. The tablist pair stays split:
  biome's `noDuplicateProperties` reads a nested `@variant` setting the same
  property as a duplicate and fails the build — recorded so it is not remerged.
- Note that fumadocs ships its own gutter for `lines`-meta fences, which cannot
  be suppressed from here and would paint a second column.

* fix(docs): drop a highlighter registration that can never fire

fumadocs-openapi calls `renderCodeBlock` with a hard-coded `"json"` from both of
its call sites (`request-tabs.js:76`, `response-tabs.js:48`), so the docs
`CodeBlock` it routes through never receives a shell language. The
`getHighlighter('js', { langs: [curlJsonBodyGrammar] })` registering the
shell-scoped JSON-body injection therefore did nothing but await on every API
sample render, and the docblock claiming the grammar covers those samples was
wrong.

- Delete the call and its imports.
- State the grammar's real coverage: prose fences only, via `langs`. Both API
  reference paths are unreachable — samples are JSON, and the cURL usage tabs
  highlight client-side off fumadocs' own factory.
- Correct `code-block.tsx`'s TSDoc, which still said API samples come from
  fumadocs' own renderer. They come through this component; `UsageTab` is the
  renderer that bypasses it.
- Re-home a comment orphaned when two CSS rules merged — it had drifted onto
  the rule below and read as documenting it.
- Drop a `.nd-copy-ignore` claim about transformers emitting those nodes;
  nothing here does, and upstream parity is the reason the clone exists.
2026-08-18 15:28:02 -07:00
Vikhyath MondretiandClaude Opus 5 e522bc4c5d fix(forks): name the workspace a sync overwrites instead of "target" (#6822)
* fix(forks): name the workspace a sync overwrites instead of "target"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(forks): name the target workspace in the blocker resolution line too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:56:39 -07:00
Waleed 56a270ed54 feat(emails): add sub-processor change notification template (#6820)
* feat(emails): add sub-processor change notification template

* improvement(emails): link the objection address and preference URL
2026-08-18 14:31:50 -07:00
Vikhyath MondretiandClaude Opus 5 3a03774e42 fix(forks): stop copying connector-managed knowledge base documents (#6818)
* fix(forks): stop copying connector-managed knowledge base documents

A fork copies a KB's documents but never its connectors, so a
connector-sourced document arrives with `connector_id` nulled and its
`external_id` intact. The sync engine keys every existing/tombstone/
exclusion lookup off `connector_id`, so that copy is invisible to it -
never updated, reconciled, or purged - and `doc_connector_external_id_idx`
does not constrain it either, since its `connector_id` is NULL.

Attaching a connector in the child then re-ingests every page as a NEW
row on top of the snapshot. Each fork hop re-copies the previous hop's
orphans and adds one more generation, so a prod -> UAT -> staging chain
leaves three rows per page and a knowledge search returns the same page
three times, one of them serving content frozen at the fork date.

Exclude connector-managed documents from all four doors a document can
enter a fork through: the whole-KB content copy, the in-transaction
placeholder pre-creation, the sync-only copy into an already-mapped KB,
and the content fill (guarded for payloads planned by a pre-change
worker mid-rollout). The placeholder path matters as much as the copy
loop - filtering only the content phase would leave a permanently
archived row behind a persisted `knowledge_document` mapping. Skipped on
both sides, the reference clears like any other uncopied document's.

A document whose connector was deleted already has a null `connector_id`
(the FK is ON DELETE SET NULL) and is static in the source too, so it
still copies. One count(*) per copied KB logs what was left behind, since
a fully connector-synced KB now forks to zero documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(forks): keep the skipped-document count from failing a copied KB

The connector-managed count feeds a log line, but it sat inside the KB's
try block, so a transient failure on a COUNT(*) would roll back a copy
that had otherwise succeeded and clear every reference to it.

Move it into a helper that swallows its own error. Counting is not
copying: only the copy itself may fail a resource. Test proven red by
removing the catch - the mutation reports a knowledge-base failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(forks): clean up full-KB placeholders planned before the exclusion

The mapped-KB fill guarded a pre-change plan, but the full-KB path did
not: a placeholder planned by an old worker for a connector-managed
document is simply no longer returned by the page query, so nothing fills
it and it stays archived behind a live mapping that a remapped
document-selector still resolves to.

Report those child ids as failed documents so the shared cleanup clears
their references and drops the rows, and delete their persisted identity
so a later sync does not resolve to a row cleanup removes. Keyed on the
SOURCE being connector-managed, which can never become copyable, so it
cannot race a concurrent attempt mid-fill the way a "source is gone"
check could.

The mapping drop is now one helper shared with the mapped-KB catch.
Test proven red by removing the reconciliation block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(forks): make the stale-plan probe best-effort

The probe ran inside the KB try, so a transient SELECT would reach the
catch, roll back a complete copy, delete the child base, and clear every
reference to it. Weighing it as "load-bearing, so fail closed" was wrong:
the probe runs on EVERY copied KB that has referenced documents, while
the state it repairs exists only inside a rollout window. Failing closed
traded a common-path outage against a rare-squared one.

It now swallows its own failure with a loud error log, leaving that
pre-existing state in place rather than destroying a good copy. Test
proven red by removing the catch - the mutation reports the KB failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:23:19 -07:00
Waleed d1e3eeea9e fix(knowledge): parse the stored artifact, not the document's display name (#6817)
* fix(knowledge): parse the stored artifact, not the document's display name

A connector document's `filename` is a display name that deliberately disagrees
with the bytes on disk: the sync engine records the source file's name
(`Report.pdf`) while storing the text the connector already extracted from it
under a `.txt` key, with `mimeType: 'text/plain'`.

`processDocumentAsync` discards the processing filename the sync engine computes
and rebuilds its input from the document row, so the parser was chosen from the
display name and re-parsed extracted text as the source binary. In production
that failed 1,379 SharePoint PDFs with `Invalid PDF structure.` and silently
double-wrapped 364 spreadsheets — those reported `completed`, wrapping a second
fake sheet around the connector's own extraction, because SheetJS accepts almost
any input.

Parser selection now prefers the extension of the object actually fetched,
falling back to the filename/MIME path when the URL is not ours or the key
carries no extension a parser claims. Both ingestion paths are honest under that
rule because `fitStorageKeyName` preserves extensions through truncation: an
upload keys on its original name, a connector document keys on what it stored.

This layer is what covers the stuck-document retry sweep, which rebuilds its own
input from the same display name — the sweep is the path that reprocesses the
already-failed documents, so a fix confined to `processDocumentAsync` would have
left the remediation itself broken.

The defect predates the connectors that expose it: Box fetches Box-side text
representations for `pdf`/`docx`/`xlsx` and stores them under the source name
too, so it was latent there before SharePoint and OneDrive reached binary
formats.

`connectorArtifactFileName` now owns the `.txt` suffix that the parser choice
depends on, so the invariant is structural instead of a convention repeated at
four call sites per function.

* fix(knowledge): raise the connector sync ceiling and tie it to the stale lock

A 2,600-document library exhausted the 30-minute budget and the run was killed
mid-listing, leaving the connector's `syncing` lock set until the scheduler
reclaimed it.

Raising the ceiling is not a lone constant, because reclaiming a stale lock
flips the connector to `error` and frees it for another sync. A TTL at or below
the run ceiling would hand the lock to a successor while the first sync is still
writing — two syncs racing the same `(connectorId, externalId)` rows. The
previous values, a 1800s run against a hard-coded 120-minute TTL declared in a
different file, held that invariant only by coincidence.

Both now derive from one another, with a test pinning the margin so the next
raise cannot silently break it.
2026-08-18 14:11:47 -07:00
Waleed 1c69372cba feat(cli): follow a run, wait for one, and tail the log (#6813)
* feat(cli): follow a run, wait for one, and tail the log

Three commands the surface was missing, each polling or streaming something
the generated command layer cannot express.

`workflows run --follow` renders the SSE the execute route already emits, so
a multi-minute agent run stops printing nothing until it ends. It rides on
the generated `run` leaf rather than a sibling command — same operation, one
different response encoding — and delegates to the handler it replaced, so
every non-follow invocation still runs the generated path. Answer text,
thinking and tool calls go to stderr; only the final envelope reaches
stdout, so redirecting still yields the result. Reasoning and tool frames
need the `X-Sim-Stream-Protocol` header, which is sent only when asked for,
because negotiating also switches answer text to live chunks the server may
retract.

`workflows runs wait` closes the loop `--async` opens. Terminal is
completed, failed or cancelled; `redacting` is not, since a run whose output
is still being scrubbed is not yet a run you can read. A time pause keeps
polling because the server resumes it, and a human pause stops with the
resume command rather than burning the bound and calling it a timeout.
Distinct exit codes keep cancelled and paused from reading as failure. The
bound is `--wait-timeout` and not `--timeout`, because SIM_TIMEOUT_SECONDS
already bounds one request and two knobs of the same name hide each other.

`logs follow` tails runs as they arrive. Dedup keys on run id, not on the
timestamp: a schedule fan-out starts many runs in the same millisecond, so a
timestamp watermark either drops the siblings or reprints them. JSON output
is one object per line, because a follow never closes an array, and the
table header is printed once so columns stay aligned across polls.

* fix(cli): disclose a truncated burst, and clear a stale retry notice

Two review findings in `logs follow`, both verified against the code first.

The page budget bounds one poll so an enormous burst cannot stall the follow,
but on reaching it the live cursor was discarded: the remainder is older than
everything collected and the next poll restarts at the newest page, so those
runs were never printed and nothing said so. The budget stays — draining
without one trades a bounded poll for unbounded buffering in a process meant
to run for hours — but hitting it now warns on stderr, naming the count and
pointing at `sim logs list`. That notice is written even off a terminal,
because a piped log is where an unexplained hole is hardest to spot.

The retry notice was cleared after the empty-rows check, so a poll that
recovered but found nothing left "retrying in Ns…" on screen while the follow
was already healthy. Clearing now happens as soon as a poll succeeds.

The second test needed two failures to be worth anything: the teardown clears
the line either way, so what separates fixed from broken is whether a bare
erase lands before the second notice or only at the end. The first version
passed against the bug.

* test(cli): pin that a mixed page is the watermark, not a truncation

A page holding a run already printed proves the follow caught up, so the
truncation warning must not fire there — that is how every healthy poll
terminates, and warning would report a hole on the ordinary path. The
straggler sharing that page is still collected, because the filter takes
every unprinted row on it rather than only those above the known one.

* fix(cli): say when the requested backlog was larger than a page holds

The logs API clamps `limit` into 1–1000 rather than rejecting it, so
`logs follow -n 5000` came back with 1000 rows, anchored the floor to that
partial page, and said nothing. The seed already knew — it computes whether
a live cursor remained — but the caller discarded the answer.

Guarded on both halves. Fewer rows than asked for is only a shortfall when
more were waiting: a workspace holding ten runs answers `-n 50` with ten and
nothing is missing, so warning on the row count alone would fire on every
small workspace. The cursor is what separates the two.
2026-08-18 11:43:16 -07:00
Siddharth Ganesan c5a9b6a5ac feat(secrets): let mship add secret descriptions (#6814)
* feat(copilot): support workspace secret descriptions

* feat(copilot): save secret card descriptions

* test(copilot): cover secret card descriptions

* fix(copilot): update secret descriptions without values
2026-08-18 11:33:45 -07:00
Waleed c17043a8b7 improvement(docs): align code blocks with the platform design system (#6810)
* improvement(docs): align code blocks with the platform design system

Docs code blocks rendered in stock `github-light`/`github-dark` on fumadocs
chrome, sharing no colors, typeface, metrics, or corner radius with the app.

- Add Sim Shiki themes transcribed from emcn's Prism token colors, shared by
  the MDX pipeline and fumadocs-openapi (which highlights through its own
  instance, so the API reference was left on the GitHub palette).
- Use the mono stack the app actually renders. `tailwind.config.ts` points
  `font-mono` at `--font-martian-mono`, but nothing defines that variable, so
  every code surface in the product resolves to the system stack.
- Give blocks the platform's field chrome — `rounded-lg`, a `--border-1`
  hairline, a `--surface-5`/`--code-bg` fill — and the 13px/21px metrics of
  `Code.Viewer`. The rule keys on `figure.shiki` because two renderers emit
  these figures and that is the only join point they share.
- Number every line, from the same tokens as the in-app gutter. Padding sits
  on `.line` rather than fumadocs' `--padding-left`: that property is
  re-declared on the inner `pre` for API samples, which dropped the digits on
  top of the code.
- Collapse tabbed fences into one box with the strip as the title row, and
  align the inline-code chip with the app's markdown renderer.
- Reuse emcn's `Button`, `useCopyToClipboard`, and chip chrome constants
  instead of re-deriving them, and drop ~90 lines of `!important` overrides,
  including a rule that could never match.

* fix(docs): stop line numbers overlapping code, unify the copy glyph

The gutter opened its column by setting `padding-left` on `.line`, which never
applied: fumadocs' own rule is `.shiki:not(.not-fumadocs-codeblock *) .line`,
and `:not()` carries its argument's specificity, putting it at (0,3,0). Every
code block rendered its line number on top of the first characters.

- Drive fumadocs' `--padding-left` / `--padding-right` instead of overriding
  `.line`. Declared on the figure, the viewport, and any inner `.shiki`,
  because the variable is inherited and the nearest declaration wins — the
  class sits on the figure alone for prose fences but on the figure and the
  inner `pre` for API samples, and `--padding-right` is also written as an
  inline style on the viewport.
- Route API request/response samples through the docs `CodeBlock` via
  fumadocs-openapi's `renderCodeBlock`, so they carry the emcn copy control
  rather than fumadocs' lucide clipboard.
- Mask the emcn glyph over the one block `renderCodeBlock` cannot reach — the
  usage tabs hardcode `ClientCodeBlock` and `OperationClientOptions` exposes
  only `APIExampleSelector` — so the copy icon is identical everywhere.

* improvement(docs): reserve the gutter column without numbering one-liners

A line number on a single-line shell command has nothing to reference, and the
CLI pages are mostly single-line commands. Dropping the gutter on those blocks
was the original behaviour, but it made adjacent fences start their code 28px
apart wherever a command sat next to its output.

Reserve the column on every block so all code shares a left edge, and paint the
digit only when the fence has more than one line.

* fix(docs): drop the gutter entirely on single-line fences

Reserving the column but leaving it blank gave one-line commands a 44px indent
with nothing in it, which reads as a rendering fault rather than as alignment.

Gate the column and the digit together, so a single-line fence keeps fumadocs'
default padding and a multi-line one gets both.

* fix(docs): stop the copy-button CSS restyling emcn's own Button

The rules added for fumadocs' copy button matched on `aria-label` alone, so
they also hit the emcn `Button` this app renders — re-declaring geometry,
radius, color, and a `background: none` that killed its hover, and pinning docs
to today's `buttonVariants` values with no failure signal if those change.

- Qualify every one with `:has(> svg[class*="lucide"])`, the same scoping the
  mask rules already used, so they reach only the block fumadocs renders.
- Stroke the masked glyph at 1.25 to match `Button size='icon'`, which
  overrides the icon's authored 1.55. The two copy glyphs were rendering at
  different weights — the mismatch the mask exists to remove.
- Drop `.line::after { content: none }`: it cannot outrank fumadocs' (0,4,1)
  rule, and no fence in `content/` uses the `lines` meta it guarded against.
- Drop the `!important` and the redundant viewport selector on `--padding-left`;
  nothing declares it between the figure and the region, and nothing contests
  it at equal specificity. `--padding-right` keeps both — its inline style is
  real.
- Use emcn's `cn` where emcn class constants are merged, so they go through the
  merger that knows the `text-micro|caption|small|md` scale.
- Correct the comments the review disproved: two claimed the API reference
  still renders fumadocs' CodeBlock, which `renderCodeBlock` changed.

* fix(docs): keep the gutter padding override belt-and-braces

A simplify pass removed the `!important` and the viewport selector from
`--padding-left` as provably redundant, and on the numbers they are: fumadocs
declares the property at (0,2,0) while these selectors are (0,3,1) and (0,4,1),
and nothing declares it on the viewport.

Restore both anyway. Getting this wrong paints the line numbers on top of the
code — a regression this PR already shipped once — and the specificity of
`:has()` and `:not()` is easy to miscount in exactly that direction. The comment
now records both that the override is redundant on paper and why it stays.

* feat(docs): highlight the curl JSON request body as JSON

A `curl -d '{…}'` payload is one single-quoted string to a shell, so the same
JSON that renders with colored keys in a response sample rendered as one flat
block of string color in the request sample directly above it.

Fixed with a TextMate injection rather than the two approaches that don't work:

- `{ include: 'source.json' }` attaches the JSON grammar but its object pattern
  only assigns `support.type.property-name.json` — the scope that colors keys —
  when it owns the opening brace. Entering mid-string, keys stay string-colored,
  which is the whole difference. So the key/value/array patterns are written out
  and name that scope directly.
- A Shiki transformer tokenizes it correctly but is a function, and the request
  tabs highlight in the browser off a `shikiOptions` object passed through RSC,
  where functions cannot cross. A grammar is plain data and reaches both sides.

An injection has to be registered on the highlighter, not passed per call, so
the API page moves to `createAPIPage` from `fumadocs-openapi/ui/base` with our
own factory, and `ApiShikiProvider` hands that same factory to the client code
blocks — both public API. The MDX pipeline preloads it through `langs`.

The opening brace requires `}`, a quoted key, or end-of-line after it, which
keeps `awk '{print $1}'` out; the end-of-line case is needed because Oniguruma
matches line by line. Verified against `jq '.[0]'`, `awk '{print $1}'`,
`grep -o 'foo'` and `echo '{}'` — none are re-colored.

* fix(docs): paint the code fill on the viewport, not the figure

The request and response panels on an API reference page rendered on different
backgrounds. Sampled from screenshots: the request panel showed the page
background (#ffffff light, --bg dark) while the response panel showed the code
surface (--surface-5 / --code-bg).

The fill was left to show through from the figure or the tab group, and those
diverge per renderer. fumadocs gives a standalone figure `bg-fd-card` but an
in-tab figure `bg-fd-secondary`, and this app forces
`--color-fd-card: transparent` on API reference pages — so zeroing the in-tab
figure's fill, expecting its group to supply one, left the request panel
transparent while the response panel's `bg-fd-secondary` group kept ours.

Paint it on the scroll viewport instead. That is the innermost box all three
renderers wrap code in, so it cannot diverge, and it no longer matters what any
ancestor sets.

* fix(docs): hide the code tab strip's scrollbar

fumadocs makes the strip `overflow-x-auto`, and an endpoint with ten status
codes overflows it in the API reference's narrow rail — leaving a scrollbar
across the bottom of a 34px header, which reads as the header being clipped
rather than as something scrollable.

Hidden the way the platform hides it on a scrolling tab strip: emcn's `TabStrip`
carries `overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden`.

The code viewport below keeps its scrollbar. There the overflow is content, and
the platform's own `Code.Container` shows one for the same reason — hiding it
would hide that a line continues.

* fix(docs): keep fumadocs-openapi's server graph out of the client bundle

The Vercel deployment went red at the commit that added `ApiShikiProvider`, and
stayed red for three commits. That component is `'use client'` and imported
`ClientCodeBlockProvider` from `fumadocs-openapi/ui/base` — an entry that also
pulls `remark`, `remark-rehype`, `@fumari/json-schema-ts` and `github-slugger`.
Importing it from a client module forces that whole server graph into the
browser bundle. Measured in `.next/static/chunks`: `json-schema-ts` in 1 chunk,
`github-slugger` in 3, `remark-rehype` in 4. A local build tolerates the weight;
a deployment with size limits does not.

`ClientCodeBlockProvider` lives in a `"use client"` module that the package does
not expose through its `exports` map, so there is no client-safe path to it.

Back to `createAPIPage` from `fumadocs-openapi/ui`, dropping the custom factory
and the provider. After: `json-schema-ts` 0 chunks, `github-slugger` 1,
`remark-rehype` 1 — the remainder is fumadocs' own client-side markdown.

The server path keeps the injection by registering it on the shared highlighter
`highlight` already resolves. What is given up is the API reference's cURL usage
tabs, which highlight in the browser off fumadocs' own factory. Prose fences
keep it, and that is where the `curl -d '{…}'` examples live — getting-started,
authentication, workflows/deployment, passing-files, triggers/webhook, in every
locale.
2026-08-18 11:12:44 -07:00
Waleed c86c084a51 fix(credential-groups): pin the clock in the enrollment tests (#6812)
`invitationExpiresAt` was dated 2026-08-18T12:00:00Z, and the code under test
compares it against the wall clock. The suite passed until that instant and has
failed for everyone since -- the same commit passed CI at 07:21Z and failed at
14:55Z with no code change between the runs.

Pin the clock to a point inside the invitation window instead. Every existing
date literal keeps its meaning and the suite stops depending on when it runs.
Re-dating the fixture to a later instant would only move the failure.
2026-08-18 08:03:37 -07:00
Waleed 34aac38112 test(table): pin the row-write behavior the suite could not fail on (#6811)
* test(table): pin the row-write behavior the suite could not fail on

Mutation testing across the branch found four changes that could be reverted
with the whole suite green, plus two comments asserting something untrue.

- Copilot row writes now assert the translated storage keys, not the literal
  keying flag. The shared table fixture uses legacy columns with no id, where
  name-to-id mapping is the identity, so the wrong keying was unobservable;
  columns whose id differs from name make it fail. Copilot is the one row-write
  surface whose keys come from a model, and under id keying the lax write path
  stores those keys verbatim and reports success.
- The three provenance transport helpers get direct cover. The route tests only
  reach mapInput and present, so each could be replaced by a constant, and a
  constant envelope reader silently downgrades every executor write to
  untracked.
- The domain provenance fixtures run against the real envelope guard instead of
  a stub that always accepted them; none of the old fixtures was a shape the
  guard admits.
- The attribution allowlist keys on the client-id reader rather than the field
  name, so it no longer misses a surface that names the acting tab positionally
  -- one of the two real suppliers was already invisible to it.

Also restores the selectedValues narrowing on the migrated row routes, so a
stale sidecar entry for a dropped column no longer rides along in the envelope,
matching what the unmigrated rows and query routes have always done.

Adds a compile-time tie between the shared table fixture and TableDefinition.
tsconfig excludes test files, so an assertion placed in one is never checked; a
new required field was a production type error and a silent no-op across every
route test.

Corrects the getRowSummaryById TSDoc, which claimed the Copilot row tool never
put executions on the wire. It did, and that narrowing is a deliberate wire
change rather than a pure saving.

* test(table): key the attribution audit on the write, not on the id reader

The allowlist matched the `readClientId` call site. That reader is a
general-purpose helper any surface may call for unrelated reasons, so an
innocent caller elsewhere in the app would be classified as naming a tab, and
aliasing or wrapping the reader would slip past it.

Match the two forms that actually attribute a write instead -- setting
`actorClientId`, or passing a second argument to the signal -- which hold
however the id was obtained and say nothing about unrelated readers.
2026-08-18 08:00:44 -07:00
Waleed 51f1d45eff perf(table): collapse sequential round trips on the table read and write paths (#6808)
* test(table): characterize the single-row route before migrating it

The single-row surface (GET/PATCH/DELETE) had no route-level tests despite
being the hottest table write path. Pin the behavior it emits today so the
migration onto the shared internal route builder is verifiable rather than
hopeful.

Covers status codes, body shapes, ISO-8601 timestamp serialization, the
access level each method demands, collaborator invocation, and the
dual-caller wire keying — session callers pass column ids through untouched
while internal-JWT callers translate names to ids in both directions.

Verified to fail: mutating the wire translator, the deleted count, and the
workspace-ownership guard each turn the corresponding tests red.

* feat(table): model wire keying and actor attribution on row write use cases

The row write use cases assumed every caller speaks column names. That holds
for /api/v2, /api/v1 and the Copilot tools, but not for the first-party grid
or the internal /api/table routes, which address cells by stable storage id.
Feeding id-keyed data through the name remap drops every key it does not
recognise — a storage id names no column name — so the write would store
nothing and still report success.

Make the wire an explicit, required property of the input rather than an
assumption. `dataKeying: 'names' | 'ids'` sits alongside `strictWrite` and is
required for the same reason: a new write surface must state which contract it
publishes. Strictness now means the same thing on either wire — an unknown
column id is refused exactly as an unknown column name already was.

Single-row writes also gain optional actor attribution, so the acting tab can
skip refetching its own write. It is optional and absent by default, so every
existing caller keeps broadcasting to all subscribers as before. Only the
single-row create, update and delete paths accept it; a batch write is not
reconciled locally by the actor and must still refetch.

The attribution pin moves with the behaviour: what selects the actor-scoped
signal is no longer which file calls it but which surface supplies an actor,
so that is now what the test pins.

Verified to fail: ignoring the keying discriminator, and dropping actor
attribution, each turn the corresponding tests red.

* perf(table): remove two round trips from every single-row update

A single-cell PATCH spends far more time in sequential round trips to a remote
Postgres than in the UPDATE it issues. Prepared statements are disabled for
PgBouncer transaction mode, so every await is a full Parse/Bind/Execute.

Two of them were avoidable.

getRowById issued the row lookup and its executions sidecar in series, but the
sidecar is keyed on the row id the caller already supplied, so it never
depended on the lookup. Issuing both together makes it one round trip. A miss
now pays one redundant sidecar read, which is the rare path and costs no extra
wall time.

The uniqueness probe ran whenever the table had any unique column, passing the
fully merged row, so editing an unrelated cell re-probed every unique column —
its own transaction plus one query per column. It is now scoped to the columns
the patch actually writes. A merge cannot newly violate uniqueness on a column
it leaves alone: that value is the one already stored, and it satisfied the
constraint when it was written.

Sized before changing: few tables declare a unique column, but write traffic
concentrates in the ones that do, so this is the larger of the two savings in
practice.

Verified to fail: reverting the probe scoping turns the covering test red.

* perf(table): start the workspace load with the table load when a workspace is asserted

resolveActiveTableContext ran two sequential round trips: load the table,
then load the workspace it turned out to live in. When the caller asserts a
workspace the second input is already in hand, so both can start together.

What makes that safe is unchanged: requireTable still compares the table's
canonical workspaceId against the assertion and reports a mismatch as
not_found. The table outcome is inspected first and unconditionally, so any
path that returns has proven the assertion equal to the canonical id, and a
failing workspace load can never replace the concealing not_found. A final
identity check on the loaded context restates the invariant at the point of
return, so even with the first check removed the function cannot hand back a
foreign workspace. Promise.allSettled keeps the discarded branch from
surfacing as an unhandled rejection.

With no asserted workspace the path stays sequential — the table load is what
reveals which workspace to load, so there is nothing to start early.

One existing assertion changed: it required the workspace load not to have
been issued yet on a mismatched assertion, which is internal sequencing rather
than caller-observable behaviour and is definitionally untrue once the loads
overlap. The observable half is kept, and two timing tests now cover the
sequencing directly.

Verified to fail: removing either mismatch check, reading the workspace
outcome first, and swapping allSettled for bare awaits each turn the
corresponding tests red.

* perf(table): read a table and its latest job in one round trip

getTableById issued the table SELECT and then awaited latestJobForTable, so
every table request paid two sequential round trips. With prepared statements
disabled for PgBouncer transaction mode every await is a full round trip, and
this loader is on essentially every table route.

The job read cannot be skipped: a table's reported rowCount is the stored
count minus the job's pendingDeleteRemaining, so dropping it would overstate
the count during a pending delete and could wrongly reject inserts as over
capacity. An opt-out flag would have made that a caller's trap. Instead the
job is read in the same statement, as a correlated jsonb subquery in the
select list — the select-list form of a LEFT JOIN LATERAL, which is what
drizzle can type here. Output is unchanged for every input.

latestJobForTable is deleted rather than left dangling: getTableById was its
only caller, and keeping it would have carried a third copy of the
exports-excluded / newest-started_at / limit-one rule. mapJobRow is now
exported so the batch path and the lateral share one implementation of the
doomedCount and pendingDeleteRemaining logic. The batch DISTINCT ON path used
by the list endpoint is untouched.

Verified to fail: dropping the export filter, reversing or re-keying the sort,
dropping the limit, dropping the correlation, loosening either doomedCount
condition, and removing the rowCount subtraction each turn tests red. Dropping
the lateral from the projection initially survived, because the shared db mock
returns queued rows regardless of predicate; a projection assertion now
covers it.

* chore(table): drop the unused single-row GET contract

Authored as groundwork for migrating the internal row routes onto the shared
builder, which is not in this change. An exported contract nothing consumes is
dead code, so it lands with the migration that needs it instead.

* fix(table): resolve strict id-keyed columns through getColumnId

assertKnownColumnIds read column.id directly, but a column id is optional —
pre-backfill columns have none and are stored under their name, which is why
getColumnId exists and is what every other consumer of the schema uses. Such columns still
exist, so a strict id-keyed write naming one would have been refused as
unknown.

Latent today: no surface yet combines dataKeying 'ids' with strictWrite. Fixed
before one does.

Verified to fail: reading column.id turns the covering test red.

* refactor(table): apply review findings from the quality pass

Four parallel reviews (reuse, simplification, efficiency, altitude) converged
on the same set. Applied:

Removed actorClientId entirely. It had no supplier anywhere in the repo, so
every call reached signalTableRowsChangedByActor(id, undefined), which is
byte-identical to the broadcast it replaced — three optional fields, three
verbatim doc blocks and a pin test asserting the empty set, all inert. It
belongs with the route migration that supplies an actor. The attribution pin
is restored to its original form.

The uniqueness probe was only half-narrowed: the patched column list was
computed and then discarded, and the probe re-derived every unique column from
the full schema. It now receives only the columns the patch touched, so a
table with several unique columns runs one query instead of all of them.

assertKnownColumnIds hand-rolled an id index and duplicated the sibling
assert's message verbatim. It now reuses buildColumnNameById — which already
keys by getColumnId, so the legacy pre-backfill column case is handled by the
shared helper rather than by a special case here — and both asserts share one
message builder.

The job field list had become two copies, one drizzle-checked and one an
unchecked sql<T> cast that could silently return undefined for a renamed
field. The lateral now derives its jsonb pairs from JOB_PROJECTION, which
satisfies Record<keyof LatestJobRow, Column>. That compile-time guarantee
replaces the runtime drift test it makes redundant.

Also: hoisted the id index out of the batch loop to match the names path,
dropped a never-supplied parameter, narrowed an over-broad parameter type,
removed a dead timer cleanup and its now-unused import, replaced dynamic
re-imports with the static one already present, hoisted a repeated stub, and
documented why filters need no keying counterpart and how laxness differs
between the two wires.

Verified to fail: removing a field from JOB_PROJECTION breaks the build in two
places.

* test(table): share one table-definition fixture factory

buildTable was copy-pasted into 13 route test files under app/api/table, each
a near-identical TableDefinition literal. A required field added to that type
would have failed 13 files individually.

packages/testing already owned createTableColumn and createTableRow but no
definition factory, and — as it turns out — did not export any of them from
the barrel, so they were unreachable from @sim/testing. Adds
createTableDefinition beside them and exports all three.

The fixture type is a structural stand-in rather than TableDefinition itself,
because packages/* must not import from apps/* — the same approach the
existing factories in that file already take.

No assertion changed. Call sites that varied a field pass it as an override;
the four files with several call sites hoist a shared options const and spread
it so each call still gets a fresh object.

* perf(table): project only the job field the row count needs

Both job reads selected the whole payload jsonb, but mapJobRow reads exactly
one number out of it, and only for a running delete. The payload also carries
the delete job's filter and an unbounded excludeRowIds array, and the latest
non-export job is read on essentially every table request — a table that once
ran a large delete would ship that id list on every read, forever.

LatestJobRow.payload becomes doomedCount, extracted in SQL. Both readers share
JOB_PROJECTION so one edit reaches the batch DISTINCT ON and the correlated
subquery alike; the compile-time constraint widens to Column | SQL rather than
being dropped.

Behaviour is identical. `->` keeps the value jsonb, which postgres-js decodes
through its built-in JSON.parse handler, so it arrives as a number with no
boundary coercion. A null payload, a payload without the key, a non-object
payload and an explicit JSON null all collapse to the same `?? 0` the previous
optional chain produced.

Sized honestly before claiming a win: payloads are small in practice today, so
this is defensive rather than impactful — it removes an unbounded growth path,
not a measured cost.

Verified against a real Postgres, not just the mocked driver: the
generated correlated subquery returns doomedCount 12 for a delete job, null
for an import job, and a null row for a table with no job.

Also from the review pass: re-homes the strictWrite explanation onto
rowWriteOptions, where six {@link} references now point; records why
replaceProjectedWireRows carries no keying discriminator; notes the one case
the uniqueness-narrowing invariant does not cover; pins the lax id-wire
passthrough with a test; and renames a parameter that misled once only its
keys were read.

* chore(table): drop two comments the code already says

One restated the identifier below it (checkUniqueConstraintsDb), and one was a
section-divider banner in the factories barrel, which the repo's comment
convention rules out. Everything else that survives is a why the code cannot
express: round-trip rationale, sql.raw input safety, the narrowing invariant
and the one case it does not cover.

* refactor(table): move the internal row routes onto the application boundary (#6809)

* refactor(table): move the single-row route onto the application boundary

The hottest table write path authorized in its own handler and queried the
database from the adapter — the two things a surface adapter must never do. It
now declares itself with defineInternalJsonRoute against the readRow, updateRow
and deleteRow use cases: 127 lines instead of 274, with no db import, no
drizzle import and no checkAccess.

Doing that surfaced why the violation existed. Write-provenance resolution
needs the canonical schema to map a caller's column key to the storage column
it certifies, and the adapter could only do that because it was already loading
the table illegally. The envelope is now split along the real seam: the adapter
reads the header and payload field, which is transport, and the use case
resolves the selections against the canonical table, which is domain.

That split has to preserve a distinction the defaulting logic would erase. An
internal caller that sends no envelope stays deliberately untracked; defaulting
it to an exact-empty stamp would certify "this write introduced no secrets" on
a runtime write that may well have introduced some. Only an interactive caller
certifies exact-empty, over the storage columns its write actually persists.

Two further changes fell out of it:

present() now receives the same { principal, input } pair its sibling hooks
responseHeaders and finalizeResponse already got. This route serves a session
and a workflow execution on one path and owes them different column keyings, so
rendering per caller kind is presentation rather than domain. That was a gap in
the builder, not a special case for this route.

tableRowWireSchema describes what the single-row routes actually return. The
contract claimed a full TableRow, carrying the executions sidecar and Date
objects — true of the list and query routes, and never true here. The hand-
rolled handler was never checked against its own contract, so the drift was
invisible until the builder started validating it.

Wire changes, both deliberate and both narrower than before: a cross-tenant
table now conceals as 404 where the old blanket handler answered 403, while an
in-workspace role denial still answers 403. Nothing in hooks/queries/tables.ts
branches on either.

Verified to fail: forcing one keying, dropping the actor, pre-resolving the
envelope, certifying an untracked internal write, and skipping the bundle
completeness check each turn the covering tests red.

* refactor(table): move the upsert route onto the application boundary

Same shape as the single-row route: declares itself against upsertTableRow,
hands the provenance envelope over unresolved, and derives its column keying
from the principal rather than assuming one.

The keying and presentation helpers the two routes shared are now in row-wire
beside the translators they wrap, so a third route does not restate them.

Two response details changed on purpose. The row now carries `position`, which
the use case always had and this route alone omitted — every other single-row
response already returned it, and the contract now describes one shape instead
of three. The upsert result also carries read-back provenance, which the route
previously assembled for itself.

The surface had no route-level tests; it has six now, covering both caller
keyings, both operations, and the envelope handover.

* refactor(table): move the enrichment-detail route onto the application boundary

The last internal adapter that queried the database for itself. It now runs
through readTableRowEnrichmentDetail, a new use case that shares
tableOperations.readRow — reading a cell's cascade breakdown is a projection of
the same row under the same role, not a second semantic operation.

Its tests move to the same seam and gain one the old suite could not express:
a cross-tenant table now conceals rather than confirming it exists.

* fix(table): mirror the storage rule when keying write provenance

storageKeyByWireKey mapped an unrecognised id-keyed key to null, but
rowDataToStorage persists that key when the caller is not writing strictly. A
cell would have been written with no provenance recorded, under a stamp still
marked complete — the same failure the bundle completeness check exists to
prevent, arriving through the keying map instead of the selection set.

The two wires genuinely differ and the code now says so: the name path drops an
unrecognised key, so it maps to null; the id path stores what it is given, so
every key it sends is a storage key.

Unreachable today, since no delegated surface uses id keying and a session
bundle is refused earlier. Fixed because the function's stated invariant — that
it mirrors how the row data itself is normalized — was not true.

Also corrects the delegated principal fixture in these tests, which used a kind
that is not in the Principal union, so the subject-id branch was never actually
exercised. It is now, and the scope check is asserted to receive the acting
principal's own subject id.

Verified to fail: restoring the schema-based lookup turns the covering test red.

* fix(table): restore executor access to the migrated row routes

The migration swapped checkSessionOrInternalAuth for the delegation policy,
and that broke every Table block call to these endpoints in two ways at once.

The old policy accepted a legacy internal token. The new one requires a
delegation token, which the executor only mints when the tool asks for it —
and none of the four table row tools did, so get/update/delete/upsert row
would each have failed with a 401. The knowledge tools already declare it,
because their routes migrated first.

Even with a valid token the operations denied the caller: readRow, updateRow,
deleteRow and upsertRow ran under a policy whose delegatedServices is
['copilot'], so the executor got a 403. They now use the tool-facing policy
that already existed for the group operations.

Neither was visible to the route tests, which mock the auth policy wholesale —
so the gap is closed at the layer that actually decides: one test pinning that
each tool requests delegation and that its operation admits the executor,
mutation-verified against both failure modes.

Also fixes findings from the review pass: the read surfaces no longer load an
executions sidecar none of them put on the wire (two readers rather than a
flag, so a caller cannot silently read an empty one); the provenance name
index is built once per batch instead of once per row; the uniqueness comment
now names the concurrent-insert race as well as the retro-added constraint;
and the presenter context gets NoInfer plus a note that the v2 builder passes
something different.

* fix(table): keep the lock on a 423 and pin the remaining wire changes

The rows error policy was built on the concealment base rather than the
lock-aware one, so a TableLockedError fell through to the generic handler and
the response lost its `lock` field — the only thing that tells a client which
lock to clear. A row write is exactly as lockable as a group mutation, so it
now shares that base.

Also pins the two wire changes the review found undocumented: a mismatched
workspace assertion answers 404 rather than 400, which is a superset of the
cross-tenant concealment already intended, and an unclassified failure answers
the builder's shared "Internal server error" rather than the old per-route
text. Both are consistent with the ~80 routes already on this builder; they are
asserted so they read as decisions rather than drift.

Verified to fail: reverting the policy base turns the lock test red.

* refactor(table): apply the quality pass

Four parallel reviews (reuse, simplification, efficiency, altitude). The
highest-value finding cut against the branch's own purpose: the rows error
policy sat in the barrel-exported route-policies module, so its import of the
1,200-line row use-case graph was paid by every one of the ~28 table routes
that can never throw a row error — the barrel's import cost went from ~1.1s to
~1.7s. row-route-policies.ts already existed for exactly this and is
deliberately not re-exported; the policy now lives there with its v2 sibling.

The upsert path still loaded an executions sidecar no surface puts on the wire,
and did it inside the write transaction, holding it open for a discarded
result. The read path got that fix earlier; the write path next to it did not.

rowKeyingForPrincipal fell through to name keying for anything that was not a
session. The operation policy admits API-key principals, so the first one to
reach these routes would have had every id-keyed cell dropped and the write
reported as successful. It is now an exhaustive switch over the two kinds the
auth policy yields — which immediately failed three tests using a principal
kind that exists nowhere in the repo, so those fixtures are real now too.

Also: reuses toWireTimestamp and createUnknownTableRowSecretProvenance instead
of re-inlining them; shares one helper for the provenance choice the update and
upsert use cases both make; keeps one canonical actorClientId doc with two
cross-references; merges two maps keyed by the same string into one; stops
round-tripping the principal through the legacy AuthType enum; hoists the
presenter function type out of both conditional branches; drops a subsumed
test; and freezes the shared locks fixture so a mutating test cannot poison its
siblings.
2026-08-17 23:43:34 -07:00
Waleed edc25aa976 docs(helm): document null as the way to remove an inherited env key (#6801)
* docs(helm): document null as the way to remove an inherited env key

Setting `app.env.KEY: ""` cannot clear a key that `app.envDefaults` sets:
the Secret template drops empty values, and the deployment template treats
an empty override as "not overridden" and still inlines the default. Helm's
own `KEY: null` deletion is the supported mechanism and already works.

The empty-string behavior is load-bearing, not a bug — every key under
`app.env` ships as a "" placeholder, and ten collide with a real
`envDefaults` value (NEXT_PUBLIC_APP_URL, BETTER_AUTH_URL, ...), so "" has
to read as "unspecified" or a default install would blank them out.

- README: document `null`, with the --reuse-values and Argo CD valuesObject
  caveats; correct the claim that `app.env` always wins over `app.envDefaults`
- values.yaml + self-hosting docs: same guidance where operators look
- sim-helm skill: record why an unset list is the wrong shape here
- tests: lock in that null removes a key and "" does not

* docs(helm): correct the verify command's chart path and scope the required-secret claim

- The verify snippet used a `sim/sim` repo alias that this chart never
  publishes; every other instruction installs from the local `./helm/sim`
  path, so the command could not run as written
- Nulling a boot-critical key only fails at template time with the
  chart-managed Secret. `existingSecret` mode skips that validation
  entirely (the chart cannot read a pre-created Secret), and under ESO the
  key must instead be mapped in externalSecrets.remoteRefs.app

* docs(helm): say null must be applied in every layer that sets a key

`null` deletes a key from the map it is applied to, not from the pod. A key
set in both `app.env` and `app.envDefaults` survives a null on the app.env
entry alone — the deployment then inlines the envDefaults value again. Under
ESO a retained `externalSecrets.remoteRefs.app` mapping keeps syncing the key
regardless of app.env.

- README and self-hosting docs: drop the "works in all three secret modes"
  shorthand and spell out that every layer setting the key must be nulled,
  including the ESO remote mapping
- tests: cover both halves — nulling only app.env restores the envDefault,
  nulling both actually removes the key
- chart 1.5.4; staging took 1.5.3 in the meantime
2026-08-17 18:16:35 -07:00
WaleedandVikhyath Mondreti 42cee278c8 fix(forking): stop a parent re-pick blanking a dependent's stored target value (#6787)
* fix(forking): stop a parent re-pick blanking a dependent's stored target value

A dependent selector (a sheet under a spreadsheet, a label under a mailbox)
is invalidated when its parent is re-picked, because the stored child no
longer exists under the new parent. That invalidation was recorded by writing
an empty string into the in-session override map — the same value the user's
own "clear this field" produces. The two are not the same thing, and the map
is submitted verbatim and written into the target workflow's configuration,
so an invalidated field cleared the target's real stored value.

The sharpest case is an undo. Re-pick a parent away from its original target,
then back. The parent nets out unchanged, so nothing is remapped and the
remap's own clearing pass never runs — but the child is still blank, and that
blank lands on a value the user never touched, with nothing in the UI showing
it happened.

Record the invalidation with a distinct marker instead. It reads as blank in
the selector, the in-block chain context, and the Sync gate, so a required
invalidated field still blocks Sync and still renders; but it is omitted from
the submitted payload rather than sent as empty, so no override is written and
the target keeps what it had. A blank the user picked themselves is still
submitted and still clears the target.

Also: skip the cascade entirely when a re-pick selects the value the field
already had, since the selector fires its change handler either way.

Fork file copy: a file whose name is already taken in a reused target folder
is de-duplicated with the same allocator the ordinary upload path uses, rather
than colliding with the folder-name unique index and being dropped from the
fork with its blob deleted.

Adds hook-level coverage for the submitted payload, which had none.

* fix(forking): preserve dependent-chain semantics

* fix(forking): preserve edits during fork sync

* fix(workflows): clear stale dependent inputs in Mothership edits

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-08-17 18:12:09 -07:00
Vikhyath Mondreti 43821e2543 improvement(files): make the row the documented owner of module context (#6803)
Follows the serve fix by stating the contract it relies on, so the next
reader does not re-derive module ownership from the key prefix.

The prefix is authoritative for where the bytes live — bucket and tenant —
and nothing more. Which module owns an object is `workspace_files.context`,
which is server-authored like the key but, unlike the key, mutable: a chat
attachment becomes a workspace file when `materialize_file` flips that
column, and encoding a mutable fact in an immutable key would mean copying
the bytes on every such transition just to restate them.

`resolveTrustedFileContext` claimed the prefix was flatly authoritative.
That claim is what made routing on it look safe. It is now scoped to what
it actually defends — a caller-supplied context can still never relabel a
private key — and `resolveStoredFileContext` is documented as the sanctioned
way to ask who owns an object rather than as a workaround.

`verifyWorkspaceFileAccess` resolved its binding with the lookup filtered to
`context = 'workspace'`, so an attachment missed the row and fell through to
object metadata, which cannot see a soft delete. It now matches either
workspace-scoped context, which is also what every caller already wanted:
the LLM-attachment and presigned-URL paths pass 'workspace' for attachment
keys today. A soft-deleted attachment is now denied on all of them.

The parse route carried the same gate and labelled parsed attachments with
the raw storage segment instead of the uploaded filename.

Module-scoped filters are deliberately untouched: the Files module, its
folder manager, forking and the workspace-file use cases all match
`context = 'workspace'` because they mean the Files module, not the bucket.
2026-08-17 18:09:14 -07:00
Waleed 9dc828f36a fix(email): greet SMTP relays with a qualified hostname instead of [127.0.0.1] (#6799)
* fix(email): greet SMTP relays with a qualified hostname instead of [127.0.0.1]

Nodemailer derives the EHLO greeting from os.hostname() and substitutes the
address literal [127.0.0.1] whenever that name contains no dot. Kubernetes pod
hostnames never contain one, so every k8s deployment introduced itself to the
relay as loopback and strict relays refused the session before any mail moved.

Send the domain the app is served from instead, as RFC 5321 4.1.4 asks, with
SMTP_EHLO_NAME to override it for relays that expect a different identity.

* fix(email): parse EHLO address literals and drop a port from the app domain

Review round 1. The bracketed branch matched a character class rather than an
address, so [::::] and [13] reached the relay as a greeting it would refuse.
Parse the address with node:net instead, which also admits the RFC 5321
IPv6: form.

getEmailDomain reports a URL host, so a deployment served on a non-default
port failed the qualified-name check and fell back to nodemailer's default —
[127.0.0.1] again on Kubernetes, the exact failure this change exists to fix.
Strip the port before validating.

* fix(email): accept any casing of the IPv6 literal tag, and stop owning SMTP_EHLO_NAME in setup

Review round 2. RFC 5321 tags the IPv6 address-literal form, and RFC 5234
makes ABNF string literals case-insensitive, so [ipv6:2001:db8::1] is as valid
as [IPv6:...]. The exact-prefix check routed it to isIPv4 and discarded it.

Drop SMTP_EHLO_NAME from the email capability's optional fields. SMTP_SECURE,
the same kind of optional transport knob on the same provider, is not modelled
there either, and claiming the field obliged the setup wizard to prompt for it
— a field whose entire purpose is to stay unset now that the default is right.
2026-08-17 18:09:02 -07:00
Waleed d17a11f29b feat(jotform): trigger a workflow on every new form submission (#6802)
* feat(jotform): trigger a workflow on every new form submission

Jotform's only webhook event is a new submission, so the block gets one
trigger. Deploying it registers the callback on the form through the API
and undeploying removes it again.

Two things about this provider needed handling:

Jotform posts submissions as multipart/form-data, which the shared webhook
body parser did not read — the delivery died as a 400 before any handler
saw it. The parser now flattens a multipart body the same way it already
flattens a urlencoded one, reducing an uploaded part to its filename so a
stray file cannot inflate the execution input.

The form's webhooks are identified by their position in the form's webhook
map, so an id captured at registration goes stale the moment any other
webhook on that form is removed. Nothing persists it; cleanup re-resolves
the id by matching the callback URL. Registration checks the same way,
because Jotform answers a rejected request with the unchanged list rather
than an error.

Answers are exposed as the parsed `rawRequest` rather than re-keyed by
question label — the labels are not unique, and the payload shape is only
documented as the raw q{qid}_{slug} map.

The trigger's region field is named `apiRegion` so it does not collide
with the block's own advanced-mode `region`.

* fix(jotform): make webhook registration idempotent and URL matching tolerant

Validated the trigger against Jotform's API reference and a captured
delivery (zulip's multipart fixture), which confirmed every mapped field —
formID, submissionID, formTitle, username, ip, type, pretty, rawRequest —
and turned up three things worth correcting.

Jotform keeps a form's webhooks as a plain list and does not treat the URL
as a key, so posting one it already holds leaves the form delivering every
submission twice. Registration now consults the list first and only posts
when the URL is absent. The documented POST sample returns the new entry as
"0", renumbering the rest, which is further reason nothing persists an id.

URL matching no longer lets a trailing slash decide the outcome. Jotform
stores the URL verbatim in every sample seen, but an exact match failing
would hard-fail deploy, and Pipedream's client normalizes the same way.

The rawRequest description claimed the field holds the submitted answers.
A real payload also carries slug, buildDate, submitSource and
jsExecutionTracker, and a file answer appears under the bare slugified
label as upload URLs rather than under a q{qid}_ key — which is also why
filtering to q-prefixed keys would silently drop file answers.

* fix(jotform): keep the callback when an active deployment still needs it

Redeploying prepares the replacement webhook row alongside the live one and
a workflow keeps its path across deployments, so both rows resolve to a
single callback on a single form. Registration adopts the callback already
present instead of posting a duplicate, which left the retired row's
cleanup deleting the one the new row had just adopted — the trigger went
silent after a redeploy that changed the trigger config.

Teardown now skips when another webhook row belonging to an active
deployment resolves to the same form and callback URL, matching how the
Telegram handler skips deleteWebhook while an active deployment still uses
the same bot. A genuine undeploy has no such row and still cleans up.
2026-08-17 18:08:38 -07:00
Waleed 0e84e92d39 fix(cli): bound, trace, and explain the requests the CLI makes (#6798)
* fix(cli): bound, trace, and explain the requests the CLI makes

Four transport gaps, all of which failed silently.

A request had no timeout, so a connection that was accepted and then never
answered hung the terminal indefinitely. `SIM_TIMEOUT_SECONDS` now bounds
one, defaulting to 3600s — deliberately above every timeout the server
itself applies, since a synchronous workflow run is allowed 3000s on a paid
plan and a tighter default would abort real work and report it as a
transport failure. `0` removes the bound, for a self-hosted deployment that
runs executions without one of its own. The caller's abort signal is
composed with the timeout rather than replaced, so neither masks the other.

Node ignores HTTP(S)_PROXY unless NODE_USE_ENV_PROXY opts in, and only from
v22.21 and v24.5, so on a network that reaches the API only through a proxy
every command failed to connect while the variable that would have fixed it
was already set. The CLI cannot enable that from inside the process — Node
reads it at startup — so it says what to do rather than bundling an HTTP
stack for a setting the platform now owns.

An API key was sent to any http:// endpoint with no signal. Now a warning,
not a refusal: http is the documented way to reach a local dev server, and
a deployment terminating TLS at a gateway is real. Loopback stays silent.

`SIM_DEBUG=1` traces method, URL, status and duration. Bodies and headers
are deliberately absent — the request carries the API key, and `secrets set`
carries the secret itself.

All four write to stderr, so a piped stdout stays parseable.

* fix(cli): make the request bound safe on every runtime it supports

Two ways the new timeout could fail before the request was made.

`AbortSignal.any` arrived in Node 20.3 and this package supports Node 20, so
composing a caller's abort signal with the timeout threw a bare TypeError on
the earliest 20.x releases. It is now used when present and composed through
an AbortController when not.

`AbortSignal.timeout` rejects a fractional millisecond outright, and past
2^31-1 ms it does not fail at all — it clamps to 1ms, so the longest timeout
anyone asked for became the shortest. The value is now rounded and refused
above what Node can actually wait, pointing at 0 for an unbounded wait.

Also unstubs env vars between tests: `stubEnv` is not undone by
`unstubAllGlobals`, so a SIM_TIMEOUT_SECONDS set for one test configured
every test after it.

* fix(cli): correct the proxy version table, and classify a timeout mid-body

`runtimeCanProxy` treated any release between 22 and 24 as capable, so on
Node 23 — which reached end of life before the backport — a configured proxy
was ignored and the CLI stayed silent about it, which is the exact failure
the warning exists to report. The table is now the two lines that shipped the
support, and anything after them.

`AbortSignal.timeout` keeps firing after `fetch` resolves, so a bound that
elapsed while the body was still being read — a large `files get` — escaped
the client's own handling and printed a raw TimeoutError stack. The top-level
handler now names it, which covers the streaming path as well as the JSON
one. A user's own Ctrl-C raises AbortError and is deliberately left alone.

* fix(cli): report a timed-out download as a timeout

`files get --output-file` streams the body to disk, and `streamToFile`
converted anything the stream threw into a write failure. So a request bound
elapsing mid-download read as `Could not write <path>: ...`, sending the
reader to check permissions and free space for a timeout they can raise, and
hiding the one instruction that resolves it.

The predicate and that instruction now live beside the timeout that raises
them, so the client, the top-level handler and the download path all say the
same thing. The wrapping stays where it is: the staged-download cleanup runs
off that failure, and rethrowing past it would leak the temporary directory.

* fix(cli): keep a sub-millisecond timeout bounded

Zero is how this function says "no bound", so rounding a positive
SIM_TIMEOUT_SECONDS down to zero inverted the request: anything under
0.0005s asked for the shortest possible timeout and got none at all, leaving
a stalled request to hang. Introduced by the rounding that fixed the
fractional-millisecond rejection.

Floored at 1ms for every positive value; only a literal 0 still disables.
2026-08-17 18:07:36 -07:00
Waleed 0b4d34137b feat(secrets): add optional descriptions to workspace secrets (#6796)
* feat(secrets): add optional descriptions to workspace secrets

Workspace secrets already have a backing credential row with a description
column, but nothing surfaced it. Teammates had no way to record what a
secret is for.

- Add a Description field to the secret detail page, matching the
  integrations credential page, gated on workspace-secret admin
- Fold the value and description editors into one Save/Discard pair and
  one unsaved-changes guard; two guards cannot coexist, since each seeds
  its own same-URL history entry
- Match descriptions in the secrets settings search
- Expose description on GET/PUT /api/v2/secrets and in the CLI

Descriptions are workspace-only: env_personal credential rows are
per-workspace mirrors of one user-global secret, so one saved there would
exist in a single workspace, and a personal secret has no teammates to
inform. The API rejects a description on personal scope rather than
silently dropping it, and omitting it on PUT leaves any existing
description untouched so a value rotation cannot erase it.

* fix(secrets): address review findings on secret descriptions

- Patch the credential detail cache optimistically on update. `onMutate`
  cancelled the detail query but only patched the lists, so a detail-backed
  editor stayed dirty after a successful save until the refetch landed —
  long enough for Discard to restore the pre-save value over the committed
  one, and for Back to open the unsaved-changes guard.
- Memoize `useSecretValue`'s returned callbacks and object, per the hook
  convention, so the composed form's save/discard stop churning per render.
- Reject a description on a personal secret in the domain layer rather than
  only at the v2 boundary. The internal credential update path accepted one
  for any type, writing data every reader hides.
- Normalize an empty description to null so the API and UI agree.
- Correct the secrets documentation, which described a Display Name field
  the detail view does not have and omitted the scope rule.
- Drop the CLI's copy of the 500-character bound; it can't import the
  contract, so a copy only drifts from the message the API already returns.
- Collapse a redundant save guard and align the description write gate with
  the render gate.

Leaves the integrations credential page byte-identical to staging.

* fix(secrets): keep the API docs example and CLI column order stable

Backward-compatibility fixes for anyone who never sets a description.

- Move the blank-to-null normalization out of the contract and into the
  route. A Zod `.transform()` on any property drops the whole request
  schema's OpenAPI examples, which had silently removed the Set Secret
  request example from the published docs.
- Append the CLI `description` column instead of inserting it before
  `updated`. `--output text` is positional, so inserting would shift every
  field an existing script cuts.
- Reject a description on a personal secret with a message that says so,
  rather than dropping the field and falling through to the generic
  "no updatable fields" error.
2026-08-17 17:33:35 -07:00
Waleed aa367a4c92 fix(sap_concur): HMAC the token cache key and wire the sendback comment (#6794)
* fix(sap_concur): key the token cache with an HMAC and document rate casing

The cache key hashed a user-chosen password with a bare SHA-256. The key
never leaves the process, but a password is low-entropy enough to
brute-force out of a plain digest if one ever reached a heap dump or a
debug log, which is what CodeQL flags. Keying the digest with a
server-side secret makes it useless without that secret. A
password-hashing KDF would be the wrong tool here: this runs on every
token fetch, and the goal is collision-free partitioning rather than
verification of a stored credential.

The body wand prompt also claimed every payload family is camelCase.
Exchange rate uploads are the exception — they take a snake_case
currency_sets array of from_crn_code, to_crn_code, start_date and rate —
and that operation is in BODY_OPS, so the blanket claim produced bodies
Concur rejects.

* fix(sap_concur): wire the travel request sendback comment through the block

move_travel_request accepts a documented query comment that Concur
applies to the sendback action, but the params branch never passed it
and the block's only comment field is gated to create_report_comment, so
the value was unreachable from the UI.

Uses a dedicated sendbackComment subblock rather than widening the
existing comment field: that one is required for create_report_comment
while this is optional, so sharing an id would both clash on
required-ness and let a value bleed between the two operations.

* fix(sap_concur): gate the sendback comment and merge duplicate TSDoc

The sendbackComment field was conditioned only on the operation, so it
rendered for submit, approve, cancel and every other workflow action
even though Concur applies the comment to sendback alone. It is now
gated on the action as well, and the params branch only forwards it for
sendback so a value retained from an earlier sendback cannot ride along
once the field is hidden.

Also folds the two consecutive TSDoc blocks left above tokenCacheKey
into one. Only the nearest block binds to the declaration, so the
separator and collision reasoning in the earlier block was detached.
2026-08-17 17:07:14 -07:00
Justin BlumencranzandWaleed Latif 0c34e69fdc feat(files): upload and safely extract ZIP archives (#6782)
* feat(files): support zip extraction

* fix(files): harden zip extraction safety

* fix(files): batch extraction notifications

* fix(files): defer rollback storage cleanup

* fix(files): restore reliable drag uploads

* fix(files): use explicit archive extraction route

* fix(ci): account for archive extraction route

* refactor(files): bound every archive extraction and trim the extractor's option surface

`maxMaterializedItems` was opt-in, so only the new unzip route bounded its output
tree — the copilot `materialize_file` and `POST /api/tools/file/manage` extract
paths had no cap on folder creation at all. An archive within MAX_ARCHIVE_ENTRIES
can still imply far more folders than files, so the cap now defaults to
MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS and applies to all three callers.

`materializedRootFolderCount` was a hand-maintained number that had to agree with
what an opaque callback would create, and the callee could not check it; drift
surfaced only as an over-limit archive slipping past the cap. It is now derived
from whether `prepareRootFolder` ran, so the contract is just "the callback
creates exactly one folder".

Also single-sources the ArchiveError -> HTTP status map (it was copied into both
the internal error policy and the tools route), drops IdempotencyService config
that only `executeWithIdempotency` reads (the extraction lease uses
atomicallyClaim/release, so no result is ever stored), hoists the duplicated
predicates in purgeCreatedWorkspaceFile and archiveWorkspaceFileFolderIfEmpty so
a lock and its write cannot diverge, and names UPLOAD_SESSION_LOCAL_PUT_MAX_BYTES
rather than overloading the multipart part size as the local single-PUT ceiling.

Adds coverage for the two guards nothing exercised: the re-validation of the
segments `prepareRootFolder` actually returned, and the default cap applying with
no caller opt-in.

UI: the drop overlay used --surface-4 unconditionally, which renders grey over the
light-mode canvas; matches the canonical overlay's --white/dark:--surface-4 and
swaps arbitrary px type sizes for named tokens.

* fix(files): bound the extraction write loop so it cannot outlive its lease

Cursor Bugbot flagged two related holes, both rooted in the write loop being
unbounded:

1. `maxDuration` is a Next.js route-segment config that serverless platforms
   enforce and self-hosted deployments do not. A slow extraction (up to 1000
   sequential uploads) could therefore outrun the six-minute lease, and
   `IdempotencyService` reclaims an expired in-progress claim — so a second unzip
   of the same archive could start beside the first.
2. Nothing rolls back a process killed mid-pass-2, so a timeout stranded the
   destination folder and every file written so far.

`decompressArchiveBufferToWorkspaceFiles` now takes an `AbortSignal` and checks it
between entries in both passes, and the extraction use case supplies a 180s
deadline. The abort unwinds through the existing all-or-nothing rollback, so the
work stops on our terms with the tree cleaned up, well inside both the route's
300s budget and the 360s lease. That closes (1) outright — the holder can no
longer outlive its lease on any platform — and converts (2) from a stranded
partial tree into a clean rollback for the slow case that actually triggers it. A
SIGKILL still cannot be caught; that needs a durable job and is out of scope here.

The overrun surfaces as a caller-fixable 413 naming the archive rather than an
opaque 500 from the raw DOMException.

* fix(files): only remap the deadline abort itself, and stop overclaiming rollback

Two follow-ups on the budget deadline, both reported by Cursor Bugbot:

`deadline.aborted` stays true for the rest of the request once the timer fires, so
it cannot decide whether *this* error was the abort. An `ArchiveError` or storage
failure thrown mid-entry after the timer fired was being relabelled as a timeout
and returned as a 413, hiding the real cause. The catch now matches the thrown
value against `deadline.reason` — `throwIfAborted()` throws exactly that object,
so the check is identity-exact and cannot capture an unrelated failure.

The message also claimed a rollback that has not necessarily happened: the budget
covers the archive download too, so it can fire before the first write, when there
is nothing to roll back. It now says the unzip was cancelled and claims nothing
about what was written. Including the download in the budget is deliberate — the
lease it has to fit inside starts earlier still — so the TSDoc says that rather
than "the extraction itself".

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-17 17:06:57 -07:00
Waleed 38075ad977 fix(sap_concur): align the integration with SAP Concur's documented API (#6790)
* fix(sap_concur): align the integration with SAP Concur's documented API

Validated all 70 tools, the block, and both proxy routes against SAP's
published API docs.

Auth:
- add the password and companyUuid to the token cache key so a request
  with the wrong password can no longer be served a cached token minted
  from someone else's
- wire the documented company-level flow (username = company UUID,
  credtype = authtoken) so companyUuid actually scopes a token
- expand the datacenter allowlist to the documented set (adds glz, apj1,
  usg, the impl hosts, and the www- twins) and drop the undocumented cn
  host; validate the returned geolocation by shape instead of membership
- coalesce concurrent token fetches so a fan-out mints one token
- forward Retry-After so 429 retries pace off Concur's own hint
- handle the errorMessageList, SCIM detail, and legacy Error.Message
  shapes instead of falling through to a generic HTTP message
- pin redirects and cap the response body

Block:
- collapse six contextType subBlocks that disagreed on their default, so
  a new block no longer seeds MANAGER for every operation
- clamp contextType to each operation's documented set
- stop requiring a userId and contextType that the default operation's
  tool does not accept, and scope the receipt fields to the upload ops
- reach six params that had no subBlock, and pass userId on travel
  request updates so a stale value cannot impersonate

Tools:
- correct response shapes that resolved to undefined: budget headers,
  budget categories, allocations, receipts, SCIM nextCursor, and the
  delete endpoints that return a bare boolean
- use the Travel Request Amount schema (currency, not currencyCode)
- narrow the four XML-only travel tools to a documented string payload
  and request application/xml
- surface real errors instead of a JSON parse failure when the proxy
  returns a non-JSON body
- cap receipt uploads at the documented sizes before downloading

Adds 106 tests covering the token cache, geolocation validation, path
traversal, and error extraction.

* fix(sap_concur): drop the removed forwardId subblock via a migration

Removing the `forwardId` subblock without a migration entry breaks
deployed workflows that still carry a value under that key.

It fed a `concur-forwardid` request header that is documented nowhere in
Concur's Receipts v4 or Image v1 references, so it was never honored.
There is no replacement subblock and the value is an opaque
caller-chosen string rather than a secret, so it is dropped outright.

* fix(sap_concur): stop swallowing upload response-read failures

The upload route caught every error from the bounded response read and
continued down the success path, so a size-limit breach or a stream
failure surfaced as an upstream success with a null or header-only body.

Concur returns Content-Length: 0 on a successful image-only upload, and
readResponseTextWithLimit already returns an empty string for that
without throwing, so dropping the catch keeps the legitimate empty-body
case working while letting real read failures reach the route's handler.

* fix(sap_concur): unblock company auth and correct the body wand prompt

The password grant marked username required, so the company-level flow —
which sends the company UUID as the token username and has no user login
— could not be configured at all, even though the request schema and
token fetch already accept companyUuid without a username. Username is
now optional for that grant and the server-side check reports which of
the two is missing. Relabels the password and companyUuid fields to say
what they carry in the company flow.

The shared body wand prompt also still described several payloads the
way they looked before this branch: quick expenses in PascalCase rather
than v4 camelCase, travel requests and expected expenses using
currencyCode where the Request v4 Amount schema uses currency, the
standard SCIM SearchRequest URN instead of Concur's, startIndex as a
search parameter when it is unsupported, and a cash advance shape that
does not match the documented request. A wand-generated body was
therefore rejected for most of the create operations it covers.

* fix(sap_concur): keep Concur's status when an error body fails to read

Removing the blanket catch from the upload read fixed one failure mode
and introduced its inverse: a cap breach or stream error while reading a
non-success body threw before the route reached the branch that
preserves Concur's status, so an upstream 4xx surfaced as a Sim 500 and
could trigger a retry the caller should not make.

Both routes now split the two cases. On a success status the body is the
result, so a read failure still propagates. On an error status the body
only supplies the message, so a read failure resolves empty and the
upstream status survives, with the message falling back to the generic
HTTP-status form.

Adds 21 tests covering both helpers over success, error, empty-body and
boundary statuses; inverting the status check turns 14 of them red.
2026-08-17 16:27:23 -07:00
Waleed 60097c89b4 fix(cli): default to the host that serves the API (#6791)
`sim.ai` answers /api/** with a 301 to `www.sim.ai`, and the client refuses
to follow redirects — a 301 rewrites a POST into a bodyless GET, so
following one turns a write into a silent no-op and hands the API key to
whatever host Location names. Defaulting to the apex therefore failed every
command for anyone who never set an endpoint. Before the refusal shipped it
was quieter and worse: reads succeeded while writes did nothing.

Also trims the provider catalogue from eleven inferred columns to seven.
`docsUrl`, `helpText`, `requiresClientGeneratedCredentialId` and the nested
`fields` are what you read once you have chosen a provider, not what you
scan to choose one, and they pushed the table well past a terminal. Both
ids stay: `credentials connect` names an OAuth provider by `serviceId`,
`credentials create` matches a service account on `providerId`.
2026-08-17 16:25:58 -07:00
Vikhyath Mondreti 1d43639df8 fix(files): serve mothership chat attachments stored under a workspace key (#6789)
A mothership chat attachment is minted with the same storage key shape as a
workspace file — `resolveUploadStorage` calls `generateWorkspaceFileKey` for
`mothership_attachment` — but its row is written with `context = 'mothership'`.
The serve route branched on the key prefix alone, so every attachment entered
the workspace-file use case, which matches on `context = 'workspace'` and
resolved it to nothing: `{"error":"FileNotFoundError","message":"File not
found"}` for every chat thumbnail and click-through. The `context=mothership`
query param on the serve URL is decorative; the route never read it.

Serve now resolves the storage context from the key's stored binding, which is
server-authored at upload and the only thing that separates the two, and passes
it into the cloud and local handlers instead of re-inferring. Genuine workspace
files still go through the authorized use case.

`verifyWorkspaceFileAccess` takes the context too, so an attachment authorizes
from its database row rather than falling through to storage-object metadata.
A soft-deleted attachment is now denied, matching workspace files.

The route tests are what let this ship: they mocked `inferContextFromKey` to
return 'mothership' for a `workspace/…` key, which it never does. With the mock
made honest, nine of them fail against the old route.
2026-08-17 16:11:53 -07:00
Waleed c8f559ae77 fix(workflows,connectors): close pre-merge audit findings (#6783)
* fix(workflows,connectors): close pre-merge audit findings

Recover subblock values orphaned by the id renames in this release, and
stop truncated knowledge-base listings from reporting themselves complete.

- Add operation-scoped subblock id migrations so a saved workflow's stored
  value survives a rename. Cloudflare create/update DNS record, ServiceNow
  read record, and Okta deactivate/delete previously lost their stored value:
  the create path substituted a seeded default (an A record where the user
  chose CNAME, and unproxied where they chose proxied), and the update path
  silently no-opped while reporting success. A migration is used rather than
  a legacy-id fallback so no subblock id carries two value spaces at runtime.
- Webflow, Zendesk: a listing that stops for a reason the connector cannot
  rule out now reports as capped instead of exhausted. A malformed envelope,
  an unfollowable continuation link, or an absent collection list previously
  read as a complete listing and let deletion reconciliation hard-delete
  every document past the truncation point.
- Sentry: pin the listing window in the request rather than inheriting the
  server default, so the range cannot silently narrow into hard deletes.
- Fork sync: a parent re-pick no longer writes a blank over a hidden optional
  dependent's stored target value, and a required field stays on screen once
  it is filled. Add hook-level coverage for the submitted payload.
- Fork file copy: a file whose name is already taken in a reused target folder
  is de-duplicated instead of dropped.
- Delete an orphaned Shopify OAuth route that built a credential from unsigned
  cookies. It had no writer, no caller, and no inbound link.
- Tailwind: drop two content globs that scanned 5.4k files to emit one unused
  rule, keeping the ones that fix brand tile icon color.
- Correct the API route-count baseline, add an Evernote docs redirect, align
  library copy with the language rules, and fix a stale turbo filter.

* fix(connectors,forking): trim the audit fixes to their minimum

A legitimacy review found several changes closed no live defect, and two
introduced problems of their own.

- Zendesk: narrow the cursor fix to a signal change. Treating a missing meta
  envelope as truncation had also made the walk follow links.next and keep
  paginating, and the ticket cursor has no page-depth valve, so a source
  advertising a next page with no meta could loop without terminating. The
  page-fetch set now matches the previous behavior; only the flag is new.
- Zendesk: drop the search next_page branch. The existing count check already
  caps every case where a missing key could lose documents.
- Webflow: drop the empty-collections flag. The sync engine already blocks the
  first sync on an empty listing and reconciles only when a second sync agrees,
  which handles a transient fault better and still removes documents when a
  source is genuinely emptied. The flag short-circuited that and suppressed
  reconciliation permanently. Restore the previous loud failure on a non-array
  envelope, and drop the unreachable collection-id filter.
- Webflow: soften a docstring that claimed pagination.total is always present.
  It is documented optional, so its absence proves nothing either way and
  treating it as unprovable truncation is the fail-safe reading.
- Sentry: drop the pinned statsPeriod. Sentry's issue search floors every query
  at 90 days in the executor regardless of the request, and the endpoint this
  release moved away from hit the same floor, so there was no window to close.
  Keep the tests and the docstring recording that.
- Fork copy: drop the renamed counter, which no caller reads.
- Repair check-block-registry, which stopped exempting migrated subblock ids
  when the migration map became an array — `in` was testing array indices.
- Drop mdx from a Tailwind content glob that emits nothing, and loosen an
  exact compiled-SQL assertion to the invariant it was pinning.

* fix(migrations): keep a ServiceNow write body off the read projection

Review findings from the first round.

- A legacy ServiceNow block can hold a Create/Update Record JSON body under
  `fields` while its stored operation is Read Records: the id served both value
  spaces before the rename, and a subblock value is not cleared when the
  operation changes. The scoped migration moved that body onto `readFields`,
  where it would reach the wire as sysparm_fields. Migration entries can now
  carry a `whenValue` predicate for the case where the stored operation alone
  cannot separate two value spaces, and the ServiceNow entry uses it to move
  only a plausible comma-separated projection.
- Type the fork copy test harness instead of using `any`, without weakening it:
  every predicate shape it does not model still throws rather than matching.
- Correct the dependent-omission comments. Omitting a parent-invalidated field
  preserves the target's stored value on Save and across an undo, where the
  parent nets out unchanged; on a Sync the written state is source-derived, so
  what it prevents there is an explicit blank reaching the fields the remap's
  clearing pass does not cover, nested tool params in particular.

Okta's migration scope is left as-is: `okta_remove_user_from_app` and the
sendEmail split shipped in the same release, so no saved block can hold legacy
state for it, and widening the scope would promote an activation-era value onto
the deactivation switch. Tests document the boundary.

* chore(forking): move the fork-sync changes to their own PR

The dependent-omission fix and the fork file-copy de-duplication are reviewed
separately in #6787. They are the only changes here that overlap #6776, and
they carry their own design tradeoff, so they should not ride along with the
unrelated audit fixes in this PR.

* fix(migrations): separate a ServiceNow write body from a projection by parsing

The guard tested for a `{` or `[` prefix, so a stored scalar body — `true`,
`"short_description"`, `42` — read as a field list and was promoted onto
`readFields`, where it would go out as sysparm_fields.

A Create/Update Record body is JSON and a projection is a bare comma-separated
field list, which is never valid JSON, so parsing is the whole test rather than
a guess at its opening character. Ambiguity still resolves to "not a
projection", leaving the value where the Create/Update control owns it.

* test(connectors,credentials): tie two assertions to what they actually prove

- Webflow: a non-array collections envelope reaching `for...of` throws, which
  is the intended loud failure. Assert the spec-mandated TypeError plus a
  single request and no write-back, rather than matching V8's wording.
- Credentials: the second guard test cannot observe "not deleted" — the proxy
  driver replays canned rows — so name it for what it does verify, that the
  reference check carries no workspace predicate and an empty RETURNING logs
  nothing. Making the driver decide the outcome would fake the database.
- Drop `vi.importActual`; a plain `drizzle-orm/pg-proxy` import works now that
  `drizzle-orm` is un-mocked.

* fix(migrations): identify a ServiceNow projection by its own shape

Recognising a write body was the wrong way round. A saved body is not always
well-formed: it can be a half-typed draft or carry an unquoted block reference,
so neither "opens with a brace" nor "fails to parse as JSON" identifies one —
and a body misread as a projection is moved to readFields with its original key
dropped, losing the draft.

Match the projection instead: a comma-separated list of ServiceNow field names,
which are word characters plus the dot of a dotted walk. A brace, quote, colon,
angle bracket or interior space fails that shape. Parsing then removes the bare
scalars that satisfy it by accident.
2026-08-17 16:02:58 -07:00
Waleed ae2147645c fix(cli): resolve findings from a full command-surface audit (#6788)
* fix(cli): resolve findings from a full command-surface audit

Exercised all 147 commands against a live deployment. Fixes the defects
that surfaced, plus the docs and generator drift they exposed.

Transport
- Stop following redirects. A bare domain that 301s to www silently
  converted POST to GET and dropped the body, so reads worked while every
  write failed with a misleading validation error and login returned 405.
  Both the client and the device flow now explain the redirect and name
  the endpoint to configure, rather than carrying credentials off-origin.
- Report a non-JSON response as one instead of printing the HTML page.
- Name the personal-API-key remedy on a workspace-key refusal, reading the
  machine-readable code the API actually sends.
- Drop union-branch noise from validation errors that contradicted itself.
- Show paging progress on stderr for multi-page fetches.

Output
- Clamp record values for table only. text is the format built for pipes,
  and it was truncating signed URLs and tool source mid-value.
- Infer timestamp, duration, bytes and boolean formatting for API-owned
  keys so undeclared commands stop printing raw ISO and float ms. Skips
  user-defined table cells and leaves json/yaml on the raw payload.
- Render a declared-but-absent field as an em dash; billing credits were
  vanishing silently.

Paths, naming and validation
- Percent-encode folder paths per segment and decode them for display, so
  a folder reads and types as the name shown in the app.
- Reject a malformed endpoint where it is set and where it resolves,
  instead of crashing with a URL parse trace.
- Request the detail level logs list's own columns need; its workflow
  column could never populate.
- Rename three commands that described themselves wrongly and align two
  flags with their siblings. Old spellings still work: hidden, warned on
  stderr, and kept out of help and docs.
- Verify whoami against the API, separating a bad key from an unreachable
  endpoint, and report the workspace by name.
- Correct the --yes help text, which advertised skipping a prompt that
  does not exist.

Docs
- Teach the docs generator that a flag required by the runtime is required,
  and that hidden commands are not documented.

* fix(cli): clear the paging progress line when a page fails

Progress is written without a trailing newline so it can be overwritten in
place, and both paging loops cleaned it up only on success. A page that
threw part-way through left `fetched 1200…` on the line the error was then
printed onto, so the two ran together.

* fix(cli): name a working API root when an endpoint redirects

The suggested endpoint was the redirect target's origin, which drops a path
prefix. A self-hosted deployment reached at https://host/sim was told to set
https://www.host — not an API root, so following the advice replaced one
broken endpoint with another.

Derive it by stripping the request's own path from the target instead, so a
prefix survives, and say nothing about --set-endpoint when the target
resolves to the endpoint already configured: a trailing-slash or path
normalization redirect keeps the origin, and naming the value the caller
already has explains nothing. The login poll shared both faults and now
shares the helper.
2026-08-17 15:55:04 -07:00
Waleed ef225f99ef fix(connectors): index Office documents and PDFs from SharePoint and OneDrive (#6785)
* fix(connectors): index Office documents and PDFs from SharePoint and OneDrive

The SharePoint and OneDrive connectors filtered their listings against a
12-item plain-text extension whitelist, so a document library of .docx, .pdf
or .xlsx files synced as "success, 0 documents" — no document, no failed row,
and no log line, which is indistinguishable from a wrong folder path. Both
whitelists had been unchanged since the connectors shipped, and Sim already
parses all of these formats for a manually uploaded knowledge base document.

Adds a shared `extractConnectorText` in connectors/utils that routes binary
document formats through the same `parseBuffer` the upload path uses, so the
OOXML zip-bomb guard and each parser's extraction limits apply. The
previously-accepted text formats stay on their exact existing path: sending
.csv through CsvParser would silently reformat every already-indexed connector
document on its next re-index.

Also logs a per-page count of files skipped for an unsupported extension.
Unsupported files are counted rather than turned into failed document rows, so
a library full of images does not fill the knowledge base with noise.

* fix(connectors): never index a degraded document extraction

`DocParser` and `PptxParser` never throw by design — on a legacy OLE `.doc`/`.ppt`
or a deck with no extractable text they return a placeholder sentence or scraped
ZIP internals so an interactive upload still shows the user something. Verified
against real OOXML fixtures: an image-only `.pptx` yields 1.9KB of
`[Content_Types].xml…` as "content", and a legacy `.ppt` yields "Unable to
extract text from PowerPoint file."

A connector sync would embed that into the vector index at scale, so it needs to
tell a real extraction from a fabricated one. Adds a declared `degraded` flag to
`FileParseMetadata`, set by exactly those two fallback paths, rather than having
callers sniff `extractionMethod`. `DocParser`'s plaintext branch stays unflagged:
a text file misnamed `.doc` is a genuine extraction.

`extractConnectorText` now raises `ConnectorTextExtractionError` when a parsed
format comes back degraded or blank, and SharePoint/OneDrive surface it as a
skipped document via the existing `markSkipped` path — so the file appears in the
knowledge base as a failed row telling the user to re-save it as DOCX/PPTX/XLSX,
instead of being silently dropped or indexed as junk.

The upload path is unaffected; it ignores the new flag.

* fix(file-parsers): register the document variants the parsers already handle

A document library holds whole format families, not just the headline extension
of each. These all extract correctly with the libraries already installed — they
were simply never registered, so every one of them was reported as an
unsupported file type:

  docm dotx  (WordprocessingML — mammoth reads word/document.xml regardless of
              the package content type)
  xlsm xlsb xltx ods  (SheetJS reads every workbook container natively)
  pptm potx  (PresentationML)
  odt odp    (OpenDocument, via a new OpenDocumentParser)

Verified against real fixtures built with jszip and SheetJS rather than assumed:
officeparser identifies a Buffer by sniffing content with `file-type`, not by the
name we pass, so the routing had to be measured. `ods` goes to the spreadsheet
parser rather than OpenDocumentParser so its output keeps per-sheet structure.

`rtf` is deliberately excluded: nothing bundled extracts it, and DocParser's
plaintext branch would pass its control words through as if they were prose.

Converts the registry from `require()` inside per-parser `try/catch` blocks that
only logged to static imports. Every parser dependency is a regular, non-optional
one, so a resolution failure should fail loudly — the old form produced a silently
**empty** registry in which every format became `Unsupported file type`, with an
empty "Supported types are:" list as the only clue. The heavy extraction libraries
are still deferred inside the individual parsers, and connectors now import the
registry lazily so the ~60 connectors that never touch a file do not pull SheetJS.

Adds registry.test.ts, which exercises the real module: index.test.ts mocks
`@/lib/file-parsers` itself, so it validated its own fake routing table and the
real registry had no coverage at all. The new test gates every member of
SupportedFileType on having a registered parser that supports buffer parsing.

* fix(file-parsers): resolve the parser registry through a Map, not object keys

The registry rewrite switched extension lookup from
`Object.keys(parsers).includes(ext)` to a bracket read on an object literal,
which also resolves inherited keys. `PARSERS['constructor']` therefore returned
`Object` — truthy, with no parse methods — so a caller-supplied extension of
`constructor` fell through to "does not support buffer parsing" instead of being
rejected as an unsupported type, and `parseFile` would have raised a TypeError.
It also disagreed with `isSupportedFileType`, which used `Object.hasOwn` and
correctly returned false for the same input.

A Map has no prototype chain to walk, so lookup and support check now agree by
construction. `isSupportedFileType` also guards a non-string argument, which the
try/catch it replaced used to absorb.
2026-08-17 15:23:29 -07:00
Waleed 746a4496ba chore(deps): upgrade next to 16.3.1, its optimizer no longer deletes live code (#6777)
* chore(deps): upgrade next to 16.3.1, its optimizer no longer deletes live code

16.3.0 was reverted in #6242 because its Turbopack optimizer modelled a bare
`return <asyncCall>()` tail call inside an async function as returning the
promise object, propagated that always-truthy fact through the caller's `await`,
and deleted everything after the resulting `if`. That shipped two dead code
paths to production: the whole `POST /api/credentials` create path, and the
insert inside `upsertAsyncToolCall`.

We reported it as vercel/next.js#96595. The fix — "[turbopack] Collapse nested
promises in the analyzer" (vercel/next.js#96601) — folds `Promise<Promise<T>>`
to `Promise<T>` in the analyzer, and was backported as #96675 and released in
16.3.1.

Verified before taking the bump:

- The minimal reproduction from the issue no longer reproduces on 16.3.1. All
  four routes keep their code; on 16.3.0 `/api/broken` lost everything after
  the `if`.
- A production build of `apps/sim` on 16.3.1 still emits the markers whose
  disappearance was the original signal: `credential_connected` (43 files),
  `acquireOrganizationUserMutationLocks` (28), and the `upsertAsyncToolCall`
  insert-path warning (10).

The `return await` hardening added to both sites in the revert stays as is, and
so does the TypeScript toolchain configuration.

16.3.1 published 2026-08-13, so it is inside the 7-day `minimumReleaseAge`
supply-chain window until 2026-08-20 and needs an exclusion to install. The
alternative is sitting on 16.2.12, whose successor we already reverted once, so
the entries go in dated and come out on the next touch of the file. The mermaid
and js-yaml exclusions aged out on 2026-08-11 and 2026-08-07 and are dropped
here per that same rule.

* fix(deps): keep the musl and win32 SWC binaries in the lockfile

The release-age exclusion only listed the four @next/swc platforms that
package.json pins, but next declares all eight as its own optionalDependencies,
so all eight are normally resolved into bun.lock. A gated optional dependency
does not fail the install — bun drops it silently — so the first install
stripped both musl variants and both win32 variants from the lockfile.

That left the Alpine devcontainer and any Windows machine with no SWC binary to
resolve. Adding the remaining four to the exclusion list restores all eight
entries at 16.3.1.

Worth knowing for the next time this happens: bun.lock is sticky here. Once an
optional dependency has been dropped, re-running the install — even with
--force, even with the age gate switched off entirely — does not bring it back,
because the resolution is not reattempted. The lockfile has to be regenerated
from a base that still contains the entries, which is why this restores
bun.lock from staging before re-applying the bump.
2026-08-17 14:04:01 -07:00
Vikhyath Mondreti 5d172b4e94 fix(search): match block references by the name the canvas shows (#6779)
* fix(search): match block references by the name the canvas shows

A block reference stores its target as the block's normalized name -
lowercased with whitespace and dots stripped - so a block titled "Send Email"
is written `<sendemail.content>`. Workflow search indexed that token as-is, so
its searchable text never contained the block's actual name.

Searching a name the way it reads on the card therefore found the block itself
and none of its references, while the run-together form found the references
and not the block. No single query could find both, and the run-together form
is the one nothing in the UI ever shows.

Resolve a reference's prefix back through the same helper that produced it, so
the reference is searched under the name the block is titled with. `rawValue`
is untouched, so the stored form keeps matching and the highlight and replace
paths, which key off it, are unaffected.

Environment references, system prefixes like `loop`, and references left
behind by a deleted block resolve to no name and stay exactly as written.

* fix(search): keep the dot-free name on a legacy reference-prefix collision

Creating or renaming a block enforces uniqueness at the normalized level, but
legacy workflows can still hold two names that collide only now that
`normalizeName` strips dots. `BlockResolver` settles that tie by letting the
dot-free name keep ownership of the key, so previously working references never
change targets.

The prefix map took whichever block was iterated last instead, so search could
name a reference after the dotted block while execution resolved it to the
dot-free one - search reporting the wrong block, which is what this is meant to
stop. Mirror the resolver's rule so both agree.
2026-08-17 13:00:24 -07:00
Vikhyath Mondreti 2732ab71e9 fix(forking): keep dependent overrides editable (#6776)
* fix(forking): keep dependent overrides editable

* fix(forking): expand configured edit cards
2026-08-17 19:50:17 +00:00
Waleed 75718ab39f fix(execution): stop a cancelled run reporting success when its wait swallows the cancellation (#6775)
* fix(execution): stop a cancelled run reporting success when its wait swallows the cancellation

Cancellation reaches a running execution over Redis pub/sub, which is
at-most-once. The engine turns that into `status: 'cancelled'` via
`signalCancelled`. But the wait handler also polled the durable Redis
cancellation key itself, and on a hit it broke out of its sleep and returned an
ordinary successful block output. The engine's `cancelledFlag` stayed false, so
a cancelled run finished as `success: true` — and with a block after the wait,
kept executing.

Whichever detector fired first won. The engine's pub/sub path normally wins by
about one round trip; when the wait's own 500ms poll landed inside that window
the cancellation was lost.

Consolidate detection in the engine, which is the only component that can
project run status: extend the once-at-start durable backstop into a poll that
runs for the life of the run and routes through `signalCancelled`. The wait
handler and loop orchestrator now observe only `ctx.abortSignal`, which the
engine aborts, so no leaf can observe a cancellation the engine has not seen.

The loop orchestrator additionally used to ignore `abortSignal.aborted`
whenever Redis was enabled, so a mid-loop timeout or client disconnect was
invisible to it, and it awaited a Redis round trip on every iteration.

Handlers that abort their own I/O off `ctx.abortSignal` are unaffected: that
surfaces as a throw, which the cancelled branch of `run` already classifies.

* docs(wait): correct the in-line wait ceiling to 5 minutes

The Wait page claimed a 10-minute cap for a synchronous wait in three places.
`MAX_INPROCESS_WAIT_MS`, the block description, the sub-block hint, and the
validation error all say 5 minutes.
2026-08-17 01:55:48 -07:00
Waleed b38e4e2f91 docs(integrations): add missing manual intros; fix light brand tiles rendering white glyphs (#6774)
* docs(integrations): add manual intro sections to eight integration pages

* fix(styling): scan blocks and ee in the tailwind content globs

* docs(snowflake): correct the unload-data capability to a table source
2026-08-17 00:49:54 -07:00
Waleed 31521d6abf fix(connectors): validate and repair the knowledge-base connector fleet (#6757)
* fix(connectors): validate and repair all 61 knowledge-base connectors

Audits every KB connector against its provider's live API documentation and
fixes what the audit found. The dominant defect class is deletion
reconciliation: the sync engine hard-deletes any stored document absent from a
"full" listing, and most connectors had a path where a truncated or errored
listing failed to set `syncContext.listingCapped`.

Highest-impact fixes:

- linear: `getDocument` was dead code. The query declared `$id: ID!` where the
  schema is `issue(id: String!)`, so every call failed variable validation.
- salesforce: `v62.0` was substituted into a `{version}` template that already
  contains the `v`, so every REST call 404'd. SOQL `LIMIT` was also used as a
  page size, silently capping every sync at 200 records.
- notion: only the first level of blocks was fetched, so tables indexed empty.
- microsoft-teams: `/messages` returns messages without replies, so no threaded
  content was ever indexed.
- gmail: an empty page discarded `nextPageToken`, which reads as a complete
  empty listing and hard-deletes every stored thread.
- confluence: the CQL path paginated with `start` and `totalSize`, neither of
  which exists on that endpoint, so label-filtered syncs stopped after one page.
- box: `supportsRefreshTokenRotation` was unset, so Box's rotated refresh token
  was discarded and every credential died on its second refresh.

Removes the Evernote integration entirely: the classic EDAM API is deprecated,
its sandbox is decommissioned, and developer tokens are no longer obtainable.

Makes SFTP host-key verification mandatory, and adds an attendee-PII opt-out to
google-calendar and google-meet (default on, so existing sources are unchanged).

Bumps the contentHash namespace for notion, google-docs and hubspot so existing
documents re-hydrate once and actually receive the content fixes above.

* fix(connectors): close swallow-into-empty and cursor-taper regressions

Ship-gate pass over the connector audit. Every finding was re-verified
against the provider's live documentation or machine-readable spec before
being acted on; several pass-3 edits were reverted rather than extended.

Correctness fixes:
- fireflies: a 2xx with an unparseable body returned an empty listing
  instead of throwing. fireflies runs a full sync every time, so a fault
  persisting across two syncs would have tombstoned all indexed docs.
- linear: same shape via `data.issues || {}` on a non-nullable connection.
- greenhouse: a 403 from a key without scorecard permission was treated as
  transient, appending `:partial` to the hash. That never matches the list
  stub, forcing full re-hydration of every candidate on every sync forever.
- google-meet: `fetchParticipants` carried a 404 swallow copied from its
  transcript siblings, freezing every speaker as "Unknown".
- airtable, asana, ashby: reverted page-size tapers applied over opaque
  cursor tokens. The cap was already enforced server-side.
- google-docs: response byte cap resolved to 800MB and could never fire.
- google-forms, google-vault, notion, sharepoint, dropbox: `getDocument`
  now throws on transient failure instead of returning null, which the
  engine reads as absence.

Security:
- Retry headers are attached non-enumerably. TypeScript `private` is
  compile-time only, so `SecureFetchHeaders.setCookies` was an own
  enumerable property that the logger serialized into sync logs.

Docs and dead code:
- Corrected six fabricated doc citations (github, jira, jsm, linear,
  google-meet, dropbox) and removed the Evernote integration entirely.

* refactor(jira): type the ADF node helpers with unknown instead of any

* fix(connectors): throw on misconfigured typeform/zendesk sources instead of returning null

A null from getDocument reads as documented absence, so on an add the
document is dropped with neither a failure counter nor a log. Both
listDocuments paths already throw on the same missing config.

* fix(connectors): keep confluence CQL page size constant; unify monday API version

The CQL search endpoint paginates by opaque cursor, and Atlassian does not
document that a cursor issued against one limit survives a request asking
for a different one. Narrowing limit to the remaining budget was the same
pattern reverted on airtable, asana, and ashby. The page size is now
constant and the cap is applied by trimming the returned page, which keeps
the cap exact without varying the request.

Monday OAuth getUserInfo hardcoded API-Version 2024-10 while every other
monday surface reads MONDAY_API_VERSION, defeating the single-source pin.

* fix(connectors): act on the final validation sweep

Findings from a read-only /validate-connector pass over all 59 changed
connectors, verified against provider specs before acting.

Silent-drop fixes (a fulfilled null from getDocument records no failure and
no log, so the document vanishes):
- ashby: candidate.info returning success with an unusable payload. Ashby
  sets contentDeferred, so this path is live.
- azure-devops: an unresolvable branch, likewise live.
- dropbox: 409 covers the whole LookupError union, and restricted_content
  and locked both mean the file still exists. Only not_found is absence.
- docusign: fetchFormValues swallowed every non-OK status, baking a
  permanently incomplete document since the hash is metadata-only.

typeform: 'all' sent response_type=started,partial,completed, but Typeform
documents only partial and completed. An unknown enum member risks a 400
that fails the whole sync, and staging omitted the parameter entirely, so
this shipped as a regression. Now requests the widest documented set.

github: removes a utf-8 blob branch justified by a misattributed quote —
that sentence describes the encoding REQUEST parameter of Create a blob;
the GET response is documented as always base64. Also corrects two comments
that hid a real drop: >1 MB files under vnd.github+json 403 rather than
returning encoding: none.

hubspot: routes HTML detection through a shared anchored helper. The loose
pattern matched angle-bracketed prose such as an email address, and
htmlToPlainText deletes the span and collapses line structure. This matters
now because the hubspot:v2: bump rewrites every live document once.

youtube: drops an invented channel-ID format quote.

* fix(connectors): converge incidentio partial hash on settled statuses

A 403 from a key without incident_updates permission, or a 404, returns on
every sync. Marking those incomplete appended :partial to a hash that then
never matched the listing stub, so the incident re-hydrated forever without
converging. Only a transient failure may mark content incomplete now, which
matches how greenhouse already treats the same class.

* fix(connectors): flag WIQL truncation unconditionally; stop swallowing docusign form-data failures

azure-devops: the 20,000-item WIQL ceiling was probed by asking for a matching
item with an id beyond the largest returned. That probe is unsound — buildWiql
orders by ChangedDate DESC while ids are assigned in creation order, so the
highest-id match is almost always inside the returned window. The probe came
back empty for genuinely truncated projects, left the listing unflagged, and
let deletion reconciliation remove every indexed item outside it. Flag
unconditionally instead; the cost is a project sitting exactly at the ceiling
not reconciling deletions until a full resync.

docusign: fetchFormValues threw on a non-404 status and then caught its own
throw, returning []. The earlier fix was a no-op. The catch now rethrows, so a
transient failure produces a failed row instead of a permanently incomplete
document under a metadata-only hash.

* fix(connectors): restore airtable AI Text indexing; floor sentry maxIssues

airtable: staging rendered object cells with a JSON.stringify fallback, so AI
Text values and nested lookup arrays reached the index. This branch replaced
that with a fixed key-probe list to stop attachment-URL hash churn, but the
probe list has no fallback — aiText ({state,isStale,value}) and nested arrays
rendered to the empty string and vanished from every document, and the
content-derived hash never moved when the text regenerated. Read `value` last,
after the existing probes, and recurse on nested arrays. The generated text is
stable rather than an expiring signed URL, so this does not reintroduce churn.

sentry: maxIssues now feeds the request limit, and Sentry rejects a non-integer.
validateConfig accepts a fractional entry, so a config that saved cleanly would
fail every sync at listing time. Staging was immune only because it sent a
hardcoded page size.
2026-08-17 00:21:04 -07:00
Vikhyath MondretiandSim Pi Agent 557a474035 feat(library): Governing AI Agents Built by Different Teams in One Enterprise Workspace (#6773)
Co-authored-by: Sim Pi Agent <pi@sim.ai>
2026-08-16 23:36:35 -07:00
Waleed cc1a278d73 fix(integrations): close defects found by an independent cold audit (#6767)
* fix(integrations): repair Update SLO and advanced OData filters

An independent audit — eight cold readers, one per integration, given no
prior findings — checked the eight integrations merged to staging today.
Two defects broke an operation outright; both are fixed here.

datadog: Update SLO rewrote every non-metric SLO to `metric`. The SLO Type
dropdown carried a `metric` default and its condition covered both create and
update, so an untouched control reached mergeSloUpdatePayload as an edit. A
metric SLO requires `query`, and the merged body carries monitor_ids or
sli_specification instead, so Datadog rejected it — Update SLO was unusable on
monitor-based and time-slice SLOs, with no way to express "keep the current
type". Update now has its own control defaulting to "Keep current".

microsoft_ad: list_users, list_groups and list_service_principals emitted
$count=true only alongside $search, so any $filter using an advanced operator
(ne, not, endsWith, startsWith on non-indexed properties) returned 400. Graph
requires $count=true with ConsistencyLevel: eventual for those. list_devices
already did this correctly; the other three now match it.

Also from the same audit: Datadog path IDs are trimmed before encoding in all
20 URL builders rather than 2, matching the existing get_monitor test.

* fix(integrations): cloudflare, crowdstrike, and mssql audit findings

From the independent cold audit. Cloudflare: DNS analytics no longer emits
fabricated min/max telemetry (Cloudflare documents both as always empty);
purge_everything defaults to specific-purge and errors when combined with
target lists; three unsourced description claims corrected; Array.isArray
guards on four older list transforms.

CrowdStrike: IOC sort placeholder corrected to the dot form (created_on.desc,
not the nonexistent created_timestamp); the 500-indicator cap relabelled as a
Sim bound rather than a CrowdStrike one; credential failures return 401 rather
than 500; prevent_no_ui noted as unenumerated.

MSSQL: introspect no longer lets the model choose the database; row and byte
caps on reads; introspection collapsed from 4N+2 to 6 fixed queries; WHERE and
identifier guards run before the connection opens so rejections are 400 not
500; SAVE TRANSACTION, OPEN/CLOSE key, DEALLOCATE and ADD SIGNATURE added to
the statement screen as two-token phrases; encrypt wording corrected to say
TDS 7.4 encryption is negotiated, not guaranteed.

* fix(splunk): publish the real output tables and read the errors Splunk sends

The docs generator parses tool source text and resolves a shared `outputs`
const only from the family's `types.ts`, so Splunk's helpers in `utils.ts` were
invisible to it: seven operations published the block's union of every output
instead of their own. Run Search and Get Search Results each shipped a ~50-row
table naming savedSearches, alerts, indexes, and apps they never return, Cancel
Search Job lost `messages`, and the four list tools lost `total`/`offset`.
Inline the four helpers into each consuming tool and delete them, since
relocating a shared const only moves the trap.

Also:

- Add a `splunk-errors` extractor for the documented `{messages: [{type, text}]}`
  envelope and set it on all twelve tools. A rejected SPL string, the most common
  failure, previously fell through to the status text and reported "Bad Request".
- Read `searchEarliestTime`/`searchLatestTime` with `asNumber`. The job entry
  documents them as bare epoch numbers, so `asString` returned null for every
  `output_mode=json` response.
- Project the `<messages>` block of the XML job-control response. It is the only
  payload that endpoint returns, so `cancel_search_job.messages` was always empty.
- Mark the nullable job outputs optional, matching the transform.
- Default `run_search` to `max_count=1000`. A oneshot search has no paging escape
  hatch and Splunk's own default is 10000 rows in one buffered response.
- Add suggested skills to `SplunkBlockMeta`, grounded in `tools.access`.

The regenerated tool metadata also picks up the Cloudflare and MSSQL output
changes from the previous commit, which were never synced.

* fix(okta,servicenow): apply integration audit findings

Cherry-picked from fix/okta-servicenow-audit-followups (6db0f54), whose base
predated the earlier audit round; the duplicate isOktaFlagEnabled that produced
is resolved in favour of the existing richer helper, which already accepts
'yes'/1/'on' as well as true/'true'.

okta: get_logs no longer advertises hasMore forever. A System Log query with no
'until' is a polling query, and Okta always returns a next link for one, even on
an empty page — so any loop driven by hasMore never terminated, including the
one our own shipped skill instructs the agent to run. errorCauses is now
surfaced, so a failed write reports the real reason instead of the useless
'Api validation failed: profile'. sendEmail routes through one coercion helper
across all four lifecycle tools. update_group's declarative fallback throws
rather than silently truncating an extensible group profile.

servicenow: attachmentLimit and limit no longer overwrite each other. Neither
assignment was scoped to an operation, so all 12 paginated operations could
silently return a row count the user never asked for — defeating the block's own
design, which gave attachmentLimit a unique id precisely to avoid this. All
seven approval states are published by ServiceNow and are now reachable from the
filter, with the space-vs-underscore punctuation documented. The five legacy
generic tools route through the shared response helpers, and the folder's only
'any' is gone. Block skills now name the semantic operations.

* chore(integrations): regenerate catalog and docs artifacts

* fix(integrations): disclose MSSQL truncation and keep Okta's poll cursor

Three defects the review round found in the audit fixes themselves.

MSSQL capped a recordset and then reported it as complete: `executeQuery`
computed `truncated`/`truncationReason` but all five statement routes returned
only `message`, `rows`, and `rowCount`, so a caller could not tell paging was
required. A shared `toRowsResponseBody` now folds the reason into `message`
for an agent reading the status line and exposes the two fields for a caller
that branches on them.

The byte ceiling also admitted a single oversized row as a lone exception, so
one `nvarchar(max)` value serialized an unbounded body — the ceiling bounded
everything except the case it exists for. A row is now admitted only when it
still fits, and the drop is disclosed rather than read as an empty table.

Okta's `get_logs` nulled `nextCursor` alongside `hasMore` on an empty polling
page. Terminating the loop is right, but the cursor is the resume handle Okta
tells callers to persist, so a scheduled workflow that hit one quiet interval
restarted from `since` and re-delivered events it had already processed. The
two answer different questions and now diverge.

Cloudflare's purge block no longer lets the invalid combination be built: the
four target fields are hidden once Purge Everything is selected, so the tool's
guard is a backstop rather than a reachable hard error.

* fix(okta,servicenow): stop sending requests the APIs reject

Okta documents `since` and `after` on the System Log as mutually
exclusive, so `get_logs` lets the cursor win rather than sending both —
the shape a scheduled poll that persists the cursor would otherwise send.

Seven boolean query params reached Okta interpolated raw, so an agent
tool call supplying `yes` was rejected. Each now routes through
`isOktaFlagEnabled`, keeping its existing send-or-omit behavior.

A cleared ServiceNow limit/offset/quantity stayed `''` through the block
mapper and was appended as a valueless `sysparm_limit=`. The mapper now
resolves a blank to undefined, and the tools skip a blank as well.

* fix(integrations): mssql guard gaps and Entra query, scope, and output findings

MSSQL read-only screen
- Screen RENAME, documented T-SQL DDL for Azure Synapse dedicated SQL pools and
  Analytics Platform System, which are reachable over TDS with exactly the
  connection fields this block exposes. `SELECT 1 RENAME OBJECT dbo.t TO t2`
  was a schema change passing an operation advertised as read-only.
- Screen the Service Broker family: RECEIVE as a word, and END/MOVE/GET
  CONVERSATION and SEND ON CONVERSATION as two-token phrases, since END closes
  every CASE. RECEIVE is a destructive read and END CONVERSATION WITH CLEANUP
  drops a conversation's messages.

MSSQL routes and block
- Build the insert statement before connecting, matching update and delete, so a
  bad identifier answers 400 instead of burning a TLS+login and returning 500.
- Declare `truncated`/`truncationReason` on the block, which the tools declare
  and the routes emit but the block left unreferenceable.

Microsoft Entra ID
- Pair `$count=true` with `ConsistencyLevel: eventual` conditionally. Graph
  documents `hasMembersWithLicenseErrors`, `isLicenseReconciliationNeeded`, and
  `identities/any(i:i/issuer)` as filterable only *without* advanced query
  parameters, and documents advanced queries as unsupported in Azure AD B2C
  tenants, so the unconditional pair broke filters that previously worked. When
  continuing from a nextLink the pairing is read off the link itself.
- Request `LicenseAssignment.Read.All` instead of `Directory.Read.All`. The
  latter was needed by `GET /subscribedSkus` alone, whose permission table names
  the former as least privileged and does not list the ReadWrite scope we hold.
- Enumerate the block's real output keys instead of a single `response` object
  no tool emits.

* fix(splunk,datadog): stop truncating searches and send mute/unmute as query params

Splunk run_search: revert the `max_count=1000` default added last pass. It was
wrong on both halves. Splunk documents the parameter as "the number of events
that can be accessible in any given status bucket. Also, in transforming mode,
the maximum number of results to store" — so for a non-transforming oneshot it
bounds status buckets, not the response, and for a transforming search (`| stats`,
`| timechart`, which is what the block's own skills generate) it capped results
at 1000 where Splunk would have stored 10000, silently. The block's `maxCount`
placeholder already read `10000`, contradicting the code. Send `max_count` only
when the caller sets it and restate the description in Splunk's own terms,
matching create_search_job. The real guidance — a oneshot buffers the whole
result set, so use Create Search Job + Get Search Results for anything large —
moves into the tool description and the search-splunk-logs skill.

Datadog mute/unmute: send `scope`, `end`, and `all_scopes` as query parameters.
`MuteMonitor` and `UnmuteMonitor` declare no `requestBody` in the authoritative
spec (docs.datadoghq.com/resources/json/full_spec_v1.json — the generated
datadog-api-client-go v1 schema omits both operations and is a subset, not the
authority); all three parameters are `in: query`. Sent as a JSON body they are
dropped, so a scoped, time-boxed mute becomes an indefinite mute across every
scope and unmute's "all scopes" never applies — answered with a 200 and the full
monitor object, so nothing surfaces.

Datadog list_monitors: imply `page=0` when a page size is set without a page.
Datadog "returns all monitors without a `page_size` limit" when `page` is absent,
so Page Size was inert from a control that reads as a bound. `page` is not
defaulted when neither is set — that would silently truncate a caller relying on
the documented return-everything behavior.

Also:

- Note in get_fired_alerts that `name=-` returns every saved search's fired
  alerts and the endpoint documents "Request parameters: None", so there is no
  count/offset to bound it.
- Fix the Splunk block's `messages` output blurb: `[{type, text}]` holds for the
  search and job-control operations, but get_search_job returns an object.
- Generalize the Datadog block's numeric coercion (`datadogPageNumber` →
  `datadogNumber`) over all 32 bare `Number()` mappings, so a typo or unresolved
  reference is omitted rather than sent as `NaN`/`null`, and an explicit `0`
  survives the old truthiness guard.
- Disclose create_event's documented 18-hour `date_happened` ceiling, and that
  send_logs' `ddsource: "custom"` is a Sim default rather than a Datadog one.

* chore(integrations): regenerate tool metadata and docs

* fix(integrations): resolve confirmed findings from cold block audit

Cloudflare: clear purge_cache advanced targets across operations; send
action_parameters/ref/logging on rate-limit rule updates; migrate off the
deprecated batch zone-settings endpoint; correct MX/URI priority wording;
stop coercing blank numerics to 0.

CrowdStrike: seed includeHidden to match Falcon's documented default.

Microsoft Entra ID: resolve a UPN to an object ID for app role assignment;
wrap 21 array outputs in items.properties so nested paths resolve.

Okta: route assign_user_role's notification flag through isOktaFlagEnabled.

ServiceNow: drop the triage skill's claim of a default limit that does not exist.

Splunk: always assign coerced numerics so raw values cannot leak through the
executor's raw-input merge.

* fix(editor,credential-group): mask secrets outside short-input and stop a per-option abort from failing a shared query

config.password only reached the short-input renderer, so eight credential
fields rendered in plaintext: private keys on ssh/sftp/pi/kalshi, the
Secrets Manager payload, the STS web-identity and SAML assertions, and the
Browser Use variables table. long-input, code, and table now honor the flag.
Code fields mask through the highlighter because react-simple-code-editor
paints its textarea transparent; the table masks every column but the first
so key/value rows stay distinguishable. A registry-walking audit test fails
both on a password flag sitting on a type that cannot honor it and on any of
the eight fields losing its flag.

credential-group threaded a per-option AbortSignal into the fetch registered
under the workspace-wide credential group list key, so closing one option
panel rejected every co-observer with an AbortError that is not a React
Query cancellation. The shared fetch now runs on its own lifecycle signal.

* fix(mssql,editor): measure the response cap in UTF-8 and stop search from unmasking secrets

capRecordset sized rows with JSON.stringify(row).length, which counts UTF-16
code units while the emitted body carries raw UTF-8. CJK is the worst case at
3 bytes per unit, so a recordset admitted as 10 MB serialized to 28 MB. Rows
are now measured with Buffer.byteLength, serialized once each, with array
punctuation charged exactly and a reserve held back for the response envelope.

Workflow search revealed masked credentials without the user touching the
field: the search panel keeps focus in its own input and only scrolls the
match into view, so typing a guess painted a private key on screen. The index
is built client-side from values already in page memory, so this was never a
privilege boundary, but masking exists to prevent incidental display and a
screenshare-visible reveal defeats it. Focus is now the only reveal, applied
through one shared policy across all four renderers.

* test(editor): drop PEM-shaped fixtures from the masking tests

The masking fixtures carried a literal OPENSSH private key header, which
GitGuardian flags as a committed secret even though the body was only the
base64 of "openssh-key-v1". The fixtures now use an obvious marker string,
and the assertions derive their match text and dot counts from the fixture
instead of restating its bytes.

* refactor(editor): drop the dead isSearchHighlighted prop

No renderer consumed it. The editor computed it at two call sites and
sub-block passed a hardcoded false into renderLabel's slot for it, so even
the one function that declared a parameter never saw the real value. Its
only live effect was in the memo comparator, where an unconsumed value
changing forced a re-render for nothing.

The name stays in the masking audit's forbidden-inputs list, which guards
against a search signal being wired back into a masking decision.
2026-08-16 23:25:16 -07:00
Waleed fd828f80d0 fix(knowledge): stop capping the unpaged knowledge-base list (#6771)
#6770 routed GET /api/knowledge through the workspace read, which carried a
10,000-row cap. Staging crossed it, and the first list request after the deploy
returned 500 with "Knowledge base list exceeds the 10000 row limit" — against
data that had served fine for days.

The cap could never have worked. Its throw was guarded by `limit === undefined`,
so it fired only for callers that had NOT asked for a page: exactly the callers
with no cursor to retry with and no way to ask for less. A paged caller never
reached it. It also read one row PAST the cap before throwing, so it refused to
serve rows it had already materialized — most of the memory was already spent.
Soft-delete cleanup reclaims archived rows only past a retention window, and not
at all where none is configured, so a workspace that archives faster than that
window crosses any fixed count on its own.

Remove it. An unpaged read is unbounded, matching the sibling internal lists
(`listTables`, workspace files), and paged callers keep their page. That fixes
the same latent 500 in the archived list, the catalog read, and the VFS name
lookup, which are all unpaged too, rather than only the surface that failed.

Two more of the same shape found while auditing for others:

- `attachConnectorTypes` threw a bare Error above its own cap, on those same
  unpaged callers. Archiving a knowledge base archives its connectors, so the
  growth curve that broke staging could not reach it — but it is the identical
  construct, and the sibling `latestJobsForTables` has no equivalent.
- The VFS path lookup read every knowledge base whose name merely CONTAINED the
  term and then exact-matched in JS, so a single-row lookup scaled with the
  workspace. It now queries the exact name and reads two rows, mirroring
  `findActiveTablesByExactName`.
2026-08-16 23:17:06 -07:00
Waleed 0844d4166b feat(jotform): add Jotform integration (#6772)
* feat(jotform): add Jotform integration

Adds 43 tools covering forms, questions, submissions, reports, webhooks,
labels, and account operations, plus the block, icon, and generated docs.

Request shapes are pinned against the API's own curl samples and the
official SDKs: PUT /form/{id}/properties and PUT /form/{id}/questions each
take a named envelope while PUT /form and the bulk-submission PUT take
their payload bare, and submission answers accept both the nested object
and the documented {qid}_{subfield} shorthand.

Skips the deprecated folder endpoints in favor of labels, and leaves out
endpoints whose response shape the docs do not publish.

* fix(jotform): harden the error envelope against quoted codes and non-JSON bodies

Jotform quotes `responseCode` on some endpoints and not others, so a
typeof-number test skipped the check on the quoted ones and turned an auth
failure into a successful tool result with empty output. Also caps the raw
body fallback, since an upstream gateway can answer with an HTML page
instead of the documented envelope.

* fix(jotform): stop duplicate question labels overwriting derived answers

Question labels are not unique — a form can carry two questions both
labelled "Email" — so keying the derived `values` map on the label alone
dropped all but the last and handed downstream workflows a confidently
wrong answer.

Every occurrence of a repeated label is now suffixed with its question ID,
rather than only the later ones, so the result does not depend on answer
order and a newly duplicated label reads as absent instead of as an
arbitrary winner. The id-keyed `answers` record was already complete and
is unchanged.

* fix(jotform): make the label-keyed answer map collision-proof

Question labels are free text, so the disambiguation key added in f9026cf
was not itself safe: a question literally labelled "Email (3)" lands on the
key generated for a duplicate "Email" at qid 3, dropping one of them. Any
key already taken is now widened again until it is free.

Accumulates in a Map rather than an object literal on the way out, since a
question labelled `__proto__` assigned onto `{}` sets the prototype instead
of an own property and disappears from the map entirely.
2026-08-16 23:13:28 -07:00
Waleed 11fe8481d0 fix(knowledge): list knowledge bases on the same authority that creates them (#6770)
* fix(knowledge): list a workspace's bases on the same authority that creates them

GET /api/knowledge authorizes the session against the canonical workspace and
then re-derives access inside the row query from a `permissions` join. Those two
no longer agree: workspace `admin` can come from an organization role alone, with
no workspace permission row behind it. Such a caller passes authorization, creates
a knowledge base, and then sees an empty list forever — the row is filtered out by
the join. Tables and files carry no equivalent join, which is why only knowledge
is affected.

Read the workspace's own rows through `getWorkspaceKnowledgeBases` once the
operation is authorized. The caller-scoped query stays only on the path with no
workspace to authorize against.

* refactor(knowledge): stop re-deriving workspace access inside the list query

The module resolves workspace authority one way everywhere — an explicit
permission row OR an organization admin role — except in the listing query,
which joined `permissions` and required a row. Both list surfaces authorized the
caller and then contradicted that authorization: an org admin could create a
knowledge base through /api/knowledge or /api/v1/knowledge and never see it
listed. Tables and files carry no such join.

Replace the caller-scoped query with `getLegacyPersonalKnowledgeBases`, which
answers only for workspace-less bases whose creator IS their only authority, and
have both surfaces read the workspace's own rows through
`getWorkspaceKnowledgeBases` after authorizing. The legacy rows keep riding along
so they stay reachable. The permissions join now appears nowhere in the module,
and the duplicated connector projection collapses onto the shared helper that
enforces the row cap.

* refactor(knowledge): clean up the module's client layer and fix two state bugs

Cleanup pass over the knowledge module — effects, state, memo, callback, React
Query, url-state, emcn, and comments — keeping the fixes that change behavior for
the better and leaving the ones that would change how the UI feels.

Bugs found and fixed:
- Opening a document flashed "Document not ready" for a frame. The chunk-row
  builder rendered the loading state as a status claim: with no document loaded
  yet it fell through to the branch that reports a missing processing status.
- A partial upload failure skipped every cache invalidation, because the throw
  jumped past them, so the list stayed missing rows the server had already
  created. Admission failures create nothing and still skip the refetch.
- The document and chunk context menus captured the row they opened on, so the
  Enable/Disable label went stale under the list's own polling. They hold an id
  and resolve against live data now.
- The action bar's "Select all"/"Clear" links were painted with `--brand-primary`,
  which is defined nowhere: the links fell back to `currentColor` and were
  indistinguishable from the text beside them.

Consistency and weight:
- Mutations no longer invalidate `detail` non-exactly for writes that touch one
  document: that key is the parent of every documents page, chunk page, tag
  definition, and connector row cached for the base.
- Dead hook surface removed (five exports with no consumer, a query instantiated
  only to reach a cache helper, a `goToPage` that only range-checked), unused
  parameters dropped, `getErrorMessage` replacing hand-rolled instanceof checks.
- `page` joins the document list's param group, so a search resets pagination in
  the same debounced write instead of writing the URL on every keystroke.
- Icons import from `@sim/emcn/icons`, the action bar composes
  `chipFilledFillTokens` instead of restating it three times, chunk cells use the
  canonical content-label chrome, and the icon-only buttons have accessible names.

* refactor(knowledge): one row reader, one visible-list composition

Follow-up from the quality pass. The two list queries had grown into near-copies
of each other — same 14-column projection, same document join, same cap check,
same row mapping — and the workspace-plus-legacy composition was pasted into both
the internal use case and the v1 route, one of which is a surface adapter that
should not be composing domain reads at all.

Both queries now read through one private projection, so a column added to one
list cannot go missing from the other half of the same rendered list, and
`listWorkspaceAndLegacyKnowledgeBases` owns the composition both surfaces call.
That merge also projects connector types once over the merged set instead of once
per source, and skips the copy-and-sort entirely when there are no legacy rows —
the common case.

Also from the review: the chunk-row memo depends on the two primitives it reads
rather than the whole polled document object, the selected chunk resolves in one
scan instead of two, an aborted chunk-search pagination throws instead of caching
a truncated result as complete, upload cache reconciliation no longer delays the
rejected promise, the key-hierarchy rule is stated once on the key factory rather
than six times at its call sites, and `TagDefinition` has one declaration.

* fix(knowledge): refresh the document list pages after a document write

Review caught a regression in the invalidation narrowing: `documents` (the list
pages) and `document` (one row) are SIBLINGS under `detail`, not parent and
child, so scoping a write to the row key left every list rendering the filename,
status, tags, `tokenCount`, and `chunkCount` it had just changed.

The key factory now exposes a `documentLists` prefix and all six document-scoped
mutations invalidate it alongside the row — the chunk mutations included, since
every chunk write moves the parent document's `tokenCount`. `detail` stays
`exact: true` where only the base's own totals move.

Also repoints the shared list-convention test at `getWorkspaceKnowledgeBases`;
it exercised the caller-scoped query this branch removed.
2026-08-16 22:21:20 -07:00
mzxchandra ad83796b69 fix(forks): stop sync demanding config the source never had (#6766)
* fix(forks): stop sync demanding config the source never had

Fork sync manufactured required re-pick rows the source never asked for, disabling
Sync and suppressing the "Fully mapped" badge. Two distinct causes landed on the
same line in `collectForkDependentReconfigs`.

Cause A - a block-level `required` applied to a nested tool param. The collector
runs one helper over both a block's own subblocks and the params of each tool in a
`tool-input`, reading the raw block config for the nested pass. A Jira Get Issue
tool inside an Agent block therefore inherited Jira's `issueKey` required-condition
even though the tool param is `visibility: 'user-or-llm'` - the agent fills it at
runtime, and `createLLMToolSchema` keeps an empty one in the model's schema
precisely so it can. The tool-row editor already strips `required` for anything not
`user-only`; the fork collector was the only surface that did not.

Cause B - demanding a value the source never had. `projectId` hangs off the
credential anchor, so a Jira block on `write` with a blank project emitted a
required row. The proof it is a collector bug: only members carrying a
`selectorKey` are emitted, so the basic selector gated while its advanced twin
(identical `required`, no `selectorKey`) did not - the same empty config blocked or
not purely on a display preference. That is also why toggling to manual mode
"fixed" it.

Neither fix subsumes the other: an emptiness guard alone leaves a *populated*
`user-or-llm` param gating after a parent swap (`effectiveDependentValue` blanks it
when the parent changed), and the visibility rule alone never touches top-level
subblocks.

Both invariants now meet in one predicate, and the tool-row rule is extracted so
the editor and the sync gate cannot drift. Rows are still emitted either way - only
the gating flag changes - so no stored dependent value is orphaned.

Also fixes the placeholder stutter in the re-pick selector ("Select select issue"),
which composed a verb onto titles that already read as instructions.

Verified end-to-end against a forked workspace: before, 3 of 7 rows were required
(including one with a populated source value); after, only a top-level populated
required field gates, Sync enables, and the badge reads "Fully mapped".

* fix(forks): ask emptiness of the raw dependent value, not the coerced one

Pre-landing review caught a silent un-gating in the new predicate: it asked
`isNonEmptyValue` about `rawSourceValue`, which flattens every non-string to `''`
for the wire contract. A multi-select dependent selector stores an array, so a
populated one reported blank and stopped gating the sync. `isNonEmptyValue`
handles arrays and non-strings deliberately - give it the raw value.

Reachable today via zoho-desk `departmentIds`, the one multi-select dependent
selector with a `selectorKey` + `dependsOn`.

Also from review:
- the canonical id is an alias, so it no longer clobbers a param that owns that
  key as its own `paramId` (first write wins)
- drop a redundant conjunct that implied a third state the code cannot reach
- document the present-but-undefined visibility case, which the resolver's
  `buildToolInputSearchConfig` branch produces routinely
- extract the placeholder-noun transform so a bare "Select" title falls back to
  the whole title instead of rendering "Select " / "No  found", and test it

Tests: non-string and empty-array source values, both verified to fail without
the fix; the basic/advanced parity case now pins both shapes rather than calling
`.every()` on an empty array; the indexer mock is reset per test so the new cases
are not order-dependent.

* fix(forks): make the post-sync collector honor tool param visibility too

Greptile caught a real asymmetry, and corrected a wrong assumption in the first
commit: `needsConfiguration` is NOT warning-only. `promote.ts:1035` skips the
target's redeploy for any workflow in that list, and `:792` also withholds its
chat-deployment carry-over.

So with only the pre-sync collector fixed, an Agent block whose Jira `issueKey`
was populated in the target and cleared by a credential remap would let the sync
through (the modal correctly treats a model-supplied param as non-blocking) and
then silently decline to redeploy that workflow - leaving the fork running its
previous deployed version with no gate and no error.

Both collectors now resolve `required` through one shared
`resolveToolParamRequired`, so the pre-sync gate and the promote path cannot
disagree about what a nested tool param means. The lookup (sub-block id, then
canonical param id, then fail closed to the block-level rule) lives in one place
instead of being duplicated.

The `@/tools/params` mock in remap-references.test.ts is now overridable so a
test can opt into an authoritative resolution; its defaults are unchanged and
the other 74 tests pass untouched. The new case is verified to fail without the
fix.

* style(forks): use TSDoc for the new test declaration comments

CLAUDE.md requires TSDoc for documentation and no non-TSDoc comments. The
vi.mock boundary note, the resolve helper, and the beforeEach mock-reset
rationale all document declarations, so doc tooling could not associate them.
In-body step comments are left as-is - they explain a flow, not a declaration.
2026-08-16 04:51:27 -07:00
Waleed aeb5624cc8 fix(integrations): close regressions found in the final validation sweep (#6764)
* fix(integrations): close regressions found in the final validation sweep

An independent read-only audit of the eight integrations merged to staging
today found defects in every one, most of them side effects of the surgery
those PRs performed on already-shipped code.

Data loss and destructive paths:
- cloudflare: restore the shipped subBlock ids on read filters so existing
  workflows keep their DNS/zone/purge filters. Losing them made
  list_dns_records return the entire zone with success: true, which a
  downstream delete fan-out would then target. The colliding write controls
  are renamed instead, chosen by blast radius.
- cloudflare: refuse an update_ruleset_rule that would tear down the rule it
  edits. PATCH is a replace, so an omitted action_parameters unbound the WAF
  managed ruleset and every override under it.
- cloudflare: split the hidden `enabled` control so a value set while drafting
  can no longer disable a live WAF or rate-limiting rule.
- cloudflare: stop `name` leaking into update_dns_record and renaming a live record.
- okta: stop a blank name overwriting a stored group name via the LLM path.
  The block guard covered only the UI.

Broken on the default path:
- microsoft_ad: update_user sent accountEnabled: "" on its own default, so
  every call left at "No Change" failed. Same tri-state defect already fixed
  for forceChangePasswordNextSignInWithMfa; `visibility` fixed alongside it.
- cloudflare: `domain` is required for self_hosted (the default app type),
  ssh, vnc and rdp; add saas_app/target_criteria and drop dash_sso, which has
  no request variant.

Silent wrong results:
- datadog: list_monitors inherited Create Monitor's tag filter and returned a
  filtered list as if complete.
- servicenow: `fields` carried both a JSON body and a projection on the three
  legacy generic operations. The regression test for this fed already-JSON and
  could not fail; it now feeds a real projection.
- splunk: cancel_search_job reported failure on success by parsing an XML body
  as JSON; readSplunkJson now tolerates it.
- okta: sendEmail === true dropped a string 'true', silently skipping the
  deactivation email.

Security:
- mssql: add writetext/updatetext/readtext to the statement screen. \bupdate\b
  cannot match UPDATETEXT, so both were reachable through the read-only path.
- crowdstrike: chunk repeated-query ids. At the published caps a single request
  built a ~68 KB query string, past typical proxy limits.

Also: splunk count=0 unbounded read, splunk pagination totals, the `nobody`
placeholder that reintroduced the namespace bug by copy-paste, okta cursor and
activate controls split per operation, servicenow sysparm_having syntax and two
required controls no longer pre-seeded with consequential values, datadog block
outputs reconciled with tool outputs, and 16 escaped apostrophes that corrupted
the published Entra docs.

One scope removed from microsoft_ad (User.Read.All). Directory.Read.All and
GroupMember.ReadWrite.All were proposed for removal and verified still required;
a test now asserts they stay.

* fix(integrations): surface corrupt Splunk bodies and partial CrowdStrike deletes

Narrows readSplunkJson's non-JSON tolerance to XML. The dispatching and
job-control endpoints answer in XML, but a body that is neither empty nor XML
was meant to be JSON, so swallowing its parse failure handed get_search_results
an empty envelope and reported a lost result set as a search with zero events.

Annotates a batched CrowdStrike delete that fails partway with the IDs its
earlier batches already removed. Falcon cannot roll those back, so a bare
failure left the caller unable to tell what was gone and a blind retry
re-targeted IDs that no longer existed.

Registers the Cloudflare subblock-ID migration the registry-stability check
requires. The suffixed read-filter IDs never shipped in a release and every
block already materializes the restored IDs, so they are dropped rather than
renamed onto values the collision guard would discard anyway.

* fix(integrations): close the three defects Bugbot found in the sweep

An execute rule sent an explicit empty action_parameters object past the new
guard, because presence was checked rather than emptiness. `{}` is the same
payload Cloudflare's schema default produces, so it unbound the managed ruleset
the guard exists to protect.

Datadog's new List Monitors pagination used a bare `Number()`, so a typo or an
unresolved reference in either advanced field reached Datadog as a literal NaN
— the pattern this same sweep fixed for Entra `top` and the Splunk numerics.

Okta's block still marked the group name required on update, blocking a
description-only update that the tool, its merge helper, and the API all accept.

* fix(integrations): confine the Splunk XML tolerance and the CrowdStrike commit list

Splitting the XML tolerance out of readSplunkJson into readSplunkDispatchJson
puts it only on the three dispatching and job-control tools that need it. The
results path can no longer read any non-JSON body as an empty envelope, so a 2xx
HTML interstitial surfaces instead of reporting a search that matched nothing.
The dispatch reader anchors on the one documented `<response>` root, so an
interstitial fails there too.

A batched delete now records the IDs Falcon echoed in `resources` rather than
the IDs that were requested. A batch can answer 200 while reporting per-ID
failures, and naming those as deleted told the caller to drop still-live
indicators from the retry.

* test(crowdstrike): pin batched partial-delete parity with an unbatched request

A 2xx envelope carrying per-ID errors is a partial success, not a failure —
failedWithoutResources fails the operation only when nothing came back at all.
The batched path already reports it exactly as a single request does, with
deletedIds naming what Falcon confirmed and errors naming what it refused. Pin
that so the contract is not mistaken for a swallowed failure.

* fix(splunk): read the dispatch XML envelope instead of discarding it

A dispatch answering in the documented XML form was replaced with an empty
object, so create_search_job and dispatch_saved_search threw a missing-sid error
after the remote job had already been created — stranding a job the caller could
no longer poll or cancel. The envelope is now projected onto the same `{ sid }`
shape output_mode=json produces, so the search ID survives.

Matching only the opening tag also accepted a body cut off mid-transfer, which
on a cancellation reported a truncated response as a successful cancel. The
pattern now spans the closing tag, so a truncated envelope falls through to
JSON.parse and throws.
2026-08-15 21:25:23 -07:00
Waleed 025ea4d2bd fix(docs): serve JSON-LD in the HTML, fix sidebar spacing, and tighten the CLI guides (#6763)
* docs(cli): use -g for the install, and cut the prose that was not pulling weight

`--global` is valid but `-g` is what every comparable CLI documents, and the
long form only came from the package README. Also drops the yarn tab: it read
`yarn global add sim`, which works on Yarn 1 only — Yarn 2 removed global
installs, so that command fails for anyone on a modern Yarn. Adds `npx sim` for
running without installing.

The guides had accumulated design rationale that belongs in code comments rather
than user docs — why the filter grammar is JSON, why the config section naming
is asymmetric, why an unexpected error keeps its stack trace. Surveying how gh,
Vercel, Turborepo, Deno, Bun and Supabase write theirs, none carry that kind of
justification, and callouts are reserved for content whose absence produces a
wrong result rather than for general asides.

So: 1016 lines to 763, and 12 callouts to 3. The three that remain are the
pairing-code check, that `sim logout` does not revoke the key, and the
`--limit 100` default on `batch-delete`/`batch-update`, which silently truncates
a larger match. Troubleshooting drops the entries whose error message already
contained its own fix and keeps the seven whose cause is not obvious.

* fix(docs): render JSON-LD as native script tags so it reaches the HTML

All four structured-data blocks — WebSite, TechArticle, BreadcrumbList,
SoftwareApplication — were rendered with `next/script`, which never emitted a
script tag. Measured on a production build, `/api-reference/getting-started`
contained zero `<script type="application/ld+json">` elements; the payload
existed only in the `__next_s` client-injection queue and the RSC flight data,
so anything reading the served HTML saw no structured data at all. React was
also logging "Encountered a script tag while rendering React component" on every
page.

`next/script` is for loading and executing JavaScript. JSON-LD is data, and
Next's own guidance is a native `<script>` in the component. `serializeJsonLd`
already escapes the `<` character to its unicode form, which is the
sanitization that guidance calls for, so only the element changes.

Same build, after: three valid tags per page with `WebSite` in `<head>`, and the
injection queue gone entirely.

* fix(docs): scope the flush-separator rule to a container's first separator

`[data-separator]:not([data-separator] ~ [data-separator])` was meant to keep the
first sidebar group flush against the top padding, but `~` only reaches siblings,
so it also matched the first separator inside every expanded folder. Under
Self-Hosting, "Install" lost its top margin and crowded the "Architecture" link
above it — 25px of gap where "Configure" and "Operate" below it had 40px.

`:first-child` expresses the intent directly. Only the four sidebar roots open
with a separator; every nested folder starts with a page, so the intended case
still goes flush and nothing else changes.

* fix(docs): move the flush-separator rule onto the separator component

Keeps the styling with the component that owns it, per the repo standard, and
lets the global rule be deleted outright rather than corrected — `global.css`
now only loses a rule in this PR. Tailwind's `first:` variant compiles to the
same `:first-child` selector, so behavior is unchanged: the build emits
`.first\:mt-0:first-child{margin-top:0}` and the prerendered HTML carries the
class on the separator.
2026-08-15 20:39:34 -07:00
Waleed 257029a60c feat(microsoft_ad): licensing, security, audit, role, and device operations (#6742)
* feat(microsoft_ad): licensing, security, audit, role, and device operations

Deepens the Microsoft Entra ID block from 12 to 36 tools against the Microsoft
Graph v1.0 reference: license assignment and tenant SKUs, password set/reset,
sign-in session revocation, authentication methods, sign-in and directory audit
logs, app role and directory role assignments, service principals, device reads,
and conditional access policy reads.

Device write (device-update, device-delete) is deliberately excluded. Both
document Directory.AccessAsUser.All as their only delegated scope, with the
higher-privileged read documented as unavailable, so supporting them would mean
requesting tenant-wide act-as-the-user directory access for two operations that
additionally require the caller to hold Intune Administrator.

Also drops an undocumented ?$select= from create_user that was silently nulling
department and accountEnabled in the response.

* fix(microsoft_ad): resolve OData filter and search by owning operation

The params mapper assigned result.filter from each filter subBlock in turn, so
the last non-empty one won regardless of the selected operation. Because a
subBlock keeps its value after the operation changes, a filter written for one
endpoint was sent to every other collection operation — invalid OData against a
different Graph resource, or a silently wrong page.

Resolves the filter and search terms from an explicit operation-to-field map
instead, so each operation reads only the field it owns.

* fix(microsoft_ad): clear non-owning filter and search on the merged inputs

The executor merges { ...inputs, ...transformedParams }, so declining to copy a
stale filter is not enough — the serialized value survives the merge and still
reaches the tool. Advanced-mode subBlocks are serialized on non-emptiness alone
and never have their condition evaluated, so the value is present even when the
field is hidden.

Write filter and search on every operation, as undefined when the operation owns
neither, so the merge clears them.

* fix(microsoft_ad): clear the MFA flag and let paged user operations continue without a User ID

The set_password MFA dropdown only wrote its key when non-empty, so the "No Change"
empty string survived `{ ...inputs, ...transformedParams }` and reached Graph in place
of a boolean. Assign it explicitly, including as `undefined`, the same way `filter` and
`search` are handled.

`list_user_app_role_assignments` and `list_user_devices` page by `@odata.nextLink`, and
both tools already treat `userId` as optional once a continuation URL is supplied. Drop
them from the required set when Next Page is filled in so pagination-only runs pass block
validation.

Also note on the reset_password output that a generated password reaches workflow outputs,
run history, and the model, matching how other tools that return secrets document exposure.

* fix(microsoft_ad): require the service principal ID only on the first page

Every other single-resource ID field pairs its condition with a matching required
rule; servicePrincipalId had none, so a first-page run could pass block validation
with an empty ID and fail inside the tool instead. Require it unless a continuation
URL is supplied, matching the paged per-user operations.

* fix(microsoft_ad): reject a continuation URL from a different collection

Every paged operation reads the one shared Next Page field, and a subBlock keeps
its value after the operation changes. Paging /users and then switching the block
to /devices short-circuited back to the user page, silently returning the previous
collection instead of the selected one.

Assert the continuation URL's terminal path segment against the collection the tool
actually reads, which also rejects a nextLink pasted from an unrelated response.
2026-08-15 19:39:40 -07:00
Waleed fed891f69d docs(cli): add a CLI docs section generated from the command tree (#6762)
* docs(cli): add a CLI section, generated from the command tree

The `sim` CLI shipped with no coverage in the docs site. Adds a fourth
top-level tab for it, and moves Academy last.

The command reference is generated. `sim` exposes 147 leaf commands across
33 groups, most of them derived at runtime from the v2 route contracts, so a
hand-written reference would be wrong the week after it was written. The
generator walks the command tree `buildProgram()` hands to commander — the
same tree the terminal parses — rather than re-deriving it from the contract,
which would be a second implementation free to describe commands nobody can
invoke. `check:cli-docs` is a zero-arg `check:*` script, so the existing audit
runner picks it up and stale pages fail CI.

Generating against the real tree surfaced a collision it had been hiding:
`bulkUpdateKnowledgeDocuments` and `updateKnowledgeDocument` both derived to
`sim knowledge documents update`. Commander resolves a duplicate to the first
match, so the bulk form shadowed the single-document one and its flags were
unreachable while still appearing in `--help`. The bulk form is now
`batch-update`, matching how `tables rows batch-delete`/`batch-update` already
handle the same REST overload, and the generator fails on any duplicate path
so the next one cannot land silently.

Five hand-written guides cover install, auth, configuration, output formats,
and scripting. Also corrects two commands in the package README that do not
exist as documented (`tables columns <tableId>`, and `--sort score:desc`,
which is JSON).

* docs(cli): document every flag from the contracts, add troubleshooting and a single-page reference

The command reference was structurally complete but said almost nothing: 223 of
377 flags rendered as "Set sort by" because the CLI only ever read flag help
from its own contract overrides, and fell back to restating the flag name.

The prose already existed. The v2 route contracts carry 931 `.describe()` calls
and the OpenAPI specs publish all of them — 327 parameters and 282 body
properties, 100% coverage — but the generated operation table dropped every one,
carrying only a per-operation summary. It now carries the field descriptions,
the path-parameter descriptions, and positional help, so `--help` and the docs
explain a flag the same way the API reference does. Placeholder descriptions are
now zero, and 147/147 commands, 377/377 flags and 130/130 arguments are
documented.

`check:cli-docs` fails on a request field with no `.describe()` rather than
letting it render as documentation that says nothing.

Also in this pass:

- Commands are root-level sidebar entries under a Commands heading rather than
  a folder, and headings are the command's description, so the table of
  contents distinguishes entries at the first word instead of repeating
  "sim knowledge documents …" fourteen times. A guard fails the build if two
  descriptions on a page collide, since they would share an anchor.
- A single-page `Complete reference` carrying all 147 commands, for in-page
  search and for agents fetching `/cli/reference.mdx`. It keys on exact command
  paths because descriptions are only unique within a group.
- A troubleshooting page, with every message copied from the source.
- Table columns are sized by a local component; the flag column was starved
  while descriptions kept most of the row empty.
- The prerelease install channels are dropped from the docs and the package
  README, which is what npm renders.

* fix(docs): match the CLI tab by path segment, and escape backslashes before pipes

`pathname.includes('/cli')` also matches `/integrations/clickup` and
`/integrations/clickhouse`, so both existing integration pages lit the CLI tab
and unlit Documentation. Matching is now per path segment. Anchoring to the
start would not work either — a non-default locale prefixes the path, as in
`/ja/cli` — so the segment is matched wherever it sits.

Table cells now double a backslash before escaping pipes. A value ending in one
turned `a\` + `|` into `a\\|`, which the table parser reads as an escaped
backslash followed by an unescaped pipe, splitting the cell early. Nothing in
the command surface contains a backslash today, so this was latent rather than
visible.

The reference page's global options table is two-column and was being wrapped in
`CommandTable`, which sizes the second column for the `Required` cell of the
three-column tables and crushed the description into 5.5rem. It now matches the
overview page, which leaves that table unsized.
2026-08-15 19:25:34 -07:00
Waleed 6a29a9e2f4 feat(mssql): add Microsoft SQL Server integration (#6739)
* feat(mssql): add Microsoft SQL Server integration

Add a Microsoft SQL Server block backed by six tools (query, execute,
insert, update, delete, introspect), mirroring the existing PostgreSQL
and MySQL integrations.

Connections go through the `mssql` (Tedious) driver: `connectionTimeout`
is top-level while `encrypt`, `trustServerCertificate`, and
`instanceName` live under `options`, and `port` is omitted when a named
instance is used. Values are bound as `@paramN` via `request.input()`;
no user value is interpolated into SQL. Identifiers are bracket-quoted
after validation and WHERE clauses run through the shared injection
guard.

Introspection reads INFORMATION_SCHEMA plus the `sys.indexes` catalog
views for tables, columns, primary keys, foreign keys, and indexes.

The icon is a placeholder database cylinder drawn with `currentColor`
until the real brand mark lands.

Requires `bun install` for the new `mssql` / `@types/mssql` deps.

* feat(mssql): use the SQL Server brand mark on a white tile

* fix(mssql): pin the validated IP and correct the introspection catalog reads

Tedious exposes `options.connector`, a hook that replaces its own
resolve-and-connect path, so the connection can be pinned to the address
`validateDatabaseHost` already approved instead of re-resolving the
hostname. `server` stays the hostname because tedious derives the TLS
`servername` from it independently of the connector, so SNI and
certificate validation survive the pin. This brings MSSQL in line with
the PostgreSQL and MySQL tools.

Named instances are dropped: tedious resolves them with a UDP SQL Server
Browser lookup issued outside the connector, and node-mssql deletes
`port` whenever `instanceName` is set, so no configuration leaves a
named instance pinned. A named instance is reachable through its static
TCP port.

Introspection fixes:
- index key columns now filter on `key_ordinal > 0`; INCLUDEd columns
  and partitioning columns both report `0` and were being returned as
  key columns, ordered ahead of the real ones
- foreign keys resolve through `sys.foreign_keys` /
  `sys.foreign_key_columns` rather than
  `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS`, whose join to
  `TABLE_CONSTRAINTS` has no row when a key references a unique index
  and so dropped the key entirely
- `is_unique` is a `bit`, which tedious maps to a boolean, so it is
  coerced rather than compared
- schemas come from `sys.schemas`, which needs only `public` and carries
  no metadata-visibility caveat

The WHERE-clause guard also covers `WAITFOR TIME`, `OPENQUERY`,
`OPENXML`, the legacy `master..sys*` compatibility views, and extended
and OLE-automation procedures beyond `xp_cmdshell`.

Regenerates the docs and catalog artifacts the icon change left stale.

* chore(mssql): commit the lockfile entries for mssql and its tedious dependency tree

* fix(mssql): make the Query operation genuinely read-only

The block label, tool description, and docs all present Query as SELECT-only
while the route ran whatever T-SQL it was given, so an agent picking
mssql_query because "it is only a SELECT" could delete rows. Screen the
statement for mutating keywords with string literals stripped, which also
catches the WITH ... DELETE form that a leading-token check would miss.

Also switch the tool barrel to absolute imports per the repo convention.

* fix(mssql): compose the shared WHERE guard and close the semicolon-less batch gap

The local validateWhereClause re-derived an older copy of the shared patterns
and scanned raw text, so it missed a bare 1=1 and false-positived on prose in a
quoted value. Delegate to validateSqlWhereClause, which masks string literals
first, and keep only the SQL Server surfaces it has no reason to know about.

T-SQL needs no statement terminator, so every semicolon-anchored stacked-query
check reads straight past `id = 1 DROP TABLE dbo.users`. Screen for a bare
statement-introducing keyword to close that; word boundaries leave ordinary
column names like updated_at and deleted_at untouched.

Export maskSqlStringLiterals so the dialect layer masks the same way the shared
guard does rather than carrying a weaker single-quote-only copy.

* fix(mssql): screen administrative T-SQL and reject batches in the read-only path

The previous round left two keyword lists maintained separately, and both were
short: DBCC, KILL, CHECKPOINT, USE, and DENY were in neither, so
`SELECT 1; DBCC SHRINKDATABASE(...)` and `id = 1 DBCC SHRINKDATABASE(...)`
both got through. Collapse them into one MSSQL_STATEMENT_KEYWORDS shared by the
query and WHERE screens so a keyword cannot be covered in one place and missed
in the other, and add the administrative commands.

Also reject any second statement after a semicolon in the Query path outright.
That closes SELECT 1; <anything> structurally instead of by naming the anything,
so the blacklist no longer has to be exhaustive to hold.

* fix(mssql): reject SQL comments in the read-only query path

A block comment placed inside a keyword splits it as far as a lexical scan is
concerned, so keyword coverage cannot settle whether the server rejoins the
halves. Refuse comments in the Query path instead of modelling the tokenizer.
A SELECT sent through this operation has no need for one, and Execute Raw SQL
still accepts them. Masking leaves comment markers intact, so a literal
containing -- still passes.

* fix(mssql): close the masker-desync bypasses and correct the catalog reads

Every T-SQL screen runs over the shared literal masker, which was written for
the MySQL dialect. Three ways to desynchronise it let real SQL hide inside what
the masker believes is a string, two of which survived the existing even-quote
check:

- a backslash before a quote. T-SQL has no backslash escape, so the server
  closes the literal where the masker swallowed the quote and runs the rest as
  code. `a='x\' DELETE FROM dbo.t WHERE b='y'` holds four quotes and masks the
  DELETE out of the keyword screen entirely, so the read-only Query operation
  would run it.
- a double quote inside a bracketed identifier, which the bracket rule missed
  because it only looked for single quotes.
- any unbalanced double quote or backtick, which the parity check did not cover.

All three now fail closed. Introspection also filters hypothetical and disabled
indexes, which were reported as if they were live, and resolves the referenced
side of a foreign key through sys.schemas so a cross-schema reference is no
longer an ambiguous bare table name. Values bound through request.input are
serialized when they are nested JSON, which the driver otherwise rejects with a
bare "Invalid string.".

* test(mssql): cover the block param merge and the operation-to-tool map

Asserts on the merged `{ ...inputs, ...buildParams(inputs) }` the generic tool
handler forwards rather than the mapper's return, since a key the mapper omits
keeps its raw subBlock value through that merge. Pins the TLS toggles to their
string form end to end — a switch subBlock would serialize `'false'`, which is
truthy, and the route contract would coerce the user's off into on — and checks
that duplicate subBlock ids agree on their seeded default.

* fix(mssql): release a pool whose connect failed, and allow a keyword with no trailing space

Only a pool handed back to the route reaches its `finally`, so a pool whose
connect rejected leaked its tarn resources — one per attempt when a bad
credential is retried. It now closes itself, and a failure to close cannot mask
the connect error the caller needs.

The read-only screen also anchored on `\s` after the opening keyword, which
refused valid reads like `SELECT*FROM dbo.users` and `SELECT(1)`. A word
boundary accepts those while still refusing `SELECTX`, and cannot loosen the
screen — the keyword and batch checks run over the whole statement regardless.

* chore(mssql): regenerate tool metadata and the integration catalog after rebase

Artifacts rebuilt with the generators rather than hand-merged, so they carry
both the mssql entries and the tools that landed on staging in parallel.

* fix(mssql): reject a parenthesised or negated constant tautology in a WHERE clause

The shared guard recognises `OR 1` but not `OR (1)`, `OR ((1))`, `OR NOT 0`, or
`OR NOT (FALSE)` — a parenthesis or a NOT between the operator and the constant
hides it. Both patterns require the constant to be the whole parenthesised term,
so a real disjunct such as `OR (1 = priority)` is untouched.

This narrows the gap rather than closing it, and is not meant to close it: an
always-true expression is not lexically decidable in general, which is why the
WHERE screen stays documented as defense-in-depth rather than a boundary.

* chore(mssql): regenerate tool metadata after rebase onto staging

Rebuilt with the generators so the artifacts carry the servicenow and
crowdstrike tools that landed on staging alongside the mssql entries.

* fix(mssql): screen trigger and state statements, and drop the space anchor on Execute

DISABLE and ENABLE were missing from the shared statement list, so
`SELECT 1 DISABLE TRIGGER dbo.audit ON dbo.users` passed the read-only screen as
a semicolon-less batch and turned auditing off. SET, BEGIN, COMMIT, and ROLLBACK
are added with them, since session and transaction state are reachable the same
way. FETCH is deliberately left out: OFFSET ... FETCH NEXT is the standard paging
clause, and screening it would reject the ordinary paged SELECT.

Execute Raw SQL anchored its allowlist on `\s`, which refused `EXEC(@sql)` —
the ordinary form of dynamic SQL, on the one operation meant to run it. It now
uses `\b`, matching the read-only screen.
2026-08-15 19:13:45 -07:00
Justin Blumencranz 7f936dc02a feat(tooling): enforce docs freshness and modernize agent skills (#6756)
* feat(docs): fail CI when generated integration docs are stale

* fix(docs): don't flag delete-then-recreated trigger pages in check mode

* docs(skills): require docs:check in the integration authoring skills

* chore(skills): migrate agent commands to native skills

* fix(skills): clean orphaned Claude projections
2026-08-15 19:08:47 -07:00
Waleed 6e5c337527 fix(sandbox): undefine the raw fetch host bridge before user code runs (#6761)
* fix(sandbox): undefine the raw fetch host bridge before user code runs

* test(sandbox): scope the hardening assertions to each execution path

* fix(sandbox): preserve the fetch global's property attributes
2026-08-15 18:53:35 -07:00
Waleed 8a44621382 feat(cloudflare): add WAF rulesets, rate limiting, Zero Trust Access, R2, Workers, and Tunnels (#6740)
* feat(cloudflare): add WAF rulesets, rate limiting, Zero Trust Access, R2, Workers, and Tunnels

Extends the Cloudflare integration past DNS/zones/cache with the security and
Zero Trust surface:

- Rulesets engine (zone-scoped): list rulesets, get a ruleset, read a phase
  entry point, and create/update/delete rules. WAF managed-rule overrides are
  surfaced through the http_request_firewall_managed entry point, since
  Cloudflare has no dedicated overrides endpoint.
- Rate limiting (zone-scoped) via the current Rulesets-based http_ratelimit
  phase, not the deprecated rate_limits endpoint.
- Cloudflare Access (account-scoped): applications, application policies,
  groups, identity providers, and service tokens.
- R2 buckets, Workers scripts/routes, and cloudflared Tunnels.

Destructive operations (delete application, delete policy, revoke service
token, delete rule, delete bucket) spell out their blast radius, and every
tool branches on the envelope's success flag rather than the HTTP status.

Security events are intentionally omitted: Cloudflare exposes them only
through the GraphQL firewallEventsAdaptive dataset, whose field list is not
documented outside schema introspection.

* fix(cloudflare): correct docs drift and remove any from the tool layer

Validation pass over all 47 Cloudflare tools against developers.cloudflare.com.

- Two tool descriptions still escaped a quote as \'. That reaches the model
  verbatim and truncates the generated MDX cell — the get_zone_settings
  `value` output row was missing from the published docs entirely. Both are
  now template literals, and the row is back.
- list_rulesets ignored pagination. The endpoint pages by cursor via
  result_info.cursors.after (not page/per_page), so a zone with many rulesets
  silently truncated with no way to page. Expose per_page + cursor and return
  the next cursor.
- The managed-ruleset override description claimed action and enabled were
  the overridable properties. They are the ones the Rulesets engine documents
  at every level, but individual managed rulesets add more: an OWASP Core
  Ruleset rule override also takes score_threshold. Corrected in both the
  tool output description and the block's action-parameters wand prompt.
  (sensitivity_level is a DDoS override, not a WAF one — deliberately absent.)
- list_tunnels/get_tunnel dropped the documented `metadata` field.
- list_r2_buckets appended order=name whenever any filter was set. `order`
  only qualifies `direction`, and `name` is its sole documented value.
- Path-interpolated IDs are trimmed, so a pasted ID with trailing whitespace
  no longer 404s.
- Replaced every `any` in the integration with checked types: a shared
  CloudflareEnvelope plus per-resource raw payload interfaces, read through
  readCloudflareResponse. The mappers in utils.ts were the widest hole —
  typing them caught four real output-shape mismatches (identity provider
  read_only, service token enabled, DNS record meta/priority, certificate
  geo_restrictions) that `any` had been hiding.
- BlockMeta only described DNS and zone work. Added templates and skills for
  the WAF, rate limiting, and Zero Trust Access surfaces the block now has.

Confirmed against the docs and left unchanged: rulesets/rate limiting are
zone-scoped and Access/R2/Workers scripts/Tunnels are account-scoped while
Workers routes are zone-scoped; tunnels live under /accounts/{id}/cfd_tunnel;
the ratelimit object is a sibling of action/expression, not nested in
action_parameters; every rate limiting period and mitigation_timeout option
matches the documented set; R2 delete returns an empty result so echoing the
requested bucket name is correct; app-nested Access policy endpoints are
current, not deprecated; and every tool fails on a 200 carrying success:false.

* fix(cloudflare): stop per-operation subblock defaults colliding on a shared id

Subblock initial values are seeded into block state keyed by subblock id —
both stores/workflows/utils.ts and lib/workflows/defaults.ts assign
`subBlocks[subBlock.id]` in a plain forEach — so two controls sharing an id
leave one stored value and the last definition in file order wins. Four ids
were duplicated with differing defaults:

- `type` was defined four times. The Access "Application Type" control is
  last, so every new block seeded `type = 'self_hosted'` and the three DNS
  record controls inherited it — Create DNS Record sent a Zero Trust
  application type as its record type. The subblock added on this branch
  broke a default on tools that shipped long before it.
- `status` was defined three times. The empty tunnel filter is last, so
  List Certificates lost its `all` default.
- `proxied` was defined three times. An empty filter is last, so Create DNS
  Record lost its explicit `false`.
- `action` was defined twice. The rate limiting dropdown is last, so the
  ruleset-rule action input was seeded `block`, quietly making "block live
  traffic" the default for a WAF custom rule the user never configured.

Give the colliding controls their own ids and map them back to the tool
params per operation, ahead of the coercions that read them, so each
operation keeps its own default. The other 17 duplicated ids agree on their
value and are left shared.

Adds tests covering each separated default plus a sweep asserting no id
carries two different seeded values, so a future duplicate goes red.

* fix(cloudflare): generate array include rules and allow bootstrapping a phase ruleset

The Access policy include wand asked for a JSON object while the tool parses
the field with parseJsonArrayParam, so generated rules failed validation.
Switch it to json-array, whose prompt reinforcement omits the object braces.

Rate limiting and WAF custom rules could only be appended to an existing
ruleset, but a zone that has never had a rule in a phase has no entry point
ruleset and returns 404, leaving no way to add the first rule. Add
cloudflare_create_ruleset for the documented POST /zones/{id}/rulesets
bootstrap, seeded with optional initial rules.

* fix(cloudflare): correct verified API defects and stop filters leaking into writes

Independent re-validation of all 48 tools against developers.cloudflare.com
turned up defects that the shipped tools would have hit on their happy path.

Delete DNS record reported every success as a failure. That endpoint is the
one Cloudflare v4 response with no envelope — its documented body is
`{"result":{"id":...}}` with no `success` — so `!data.success` was always
true. Branch on an explicit `=== false` instead.

The two replace-semantics PATCH endpoints could silently destroy live config.
Update rate limit rule defaulted a missing action to `block`, converting an
existing `log` or challenge rule into a hard block on real traffic; update
ruleset rule left action and expression optional and had no `ratelimit` or
`logging` passthrough, so updating a rate limiting rule stopped it rate
limiting. Both now require the fields the replacement needs, and the ruleset
rule carries the two nested objects through.

Access applications were unbuildable for most types: `domain` was required,
but it does not exist on the saas, app_launcher, warp, biso, dash_sso,
infrastructure, mcp, mcp_portal, or proxy_endpoint request variants. The
application type enum was also six values behind. Access group `is_default`
is an array of rule objects, not a boolean.

Purge cache merged every supplied target into one body, but the purge body is
a one-of over the five target kinds; it now names the conflict instead.

The remaining fixes are documentation drift: the priority field is MX and URI
only (an SRV record carries priority inside its content), the certificate
status filter documents only "all", the Worker tag filter takes tag:allowed
pairs, and the managed-rule override list conflated the DDoS-only
sensitivity_level with the WAF rule-level set.

Separately, controls that share a subBlock id share one stored value, and
`shouldSerializeSubBlock` short-circuits on `mode: 'advanced'` before it
evaluates `condition` — so a hidden list filter was reaching a write. A
`list_dns_records` content filter could overwrite a record's content, cache
tags could be written onto a DNS record, and the zone status enum could reach
the tunnel list, whose enum is disjoint. Filters that differ from the value
they collided with now carry their own id, remapped through one table before
any coercion. Sharings that mean the same thing everywhere are unchanged.

Aliases are cleared by explicit assignment rather than destructuring, because
the executor merges the mapper's output over the raw inputs and a merely
omitted key survives as its raw subBlock string. The tests assert on that
merged result, and three mechanical invariants now go red on a new collision:
no id spans a read filter and a written value, no dropdown id carries two
option sets, and no hidden advanced control feeds an operation that cannot
render it. That last one found the name filter reaching three list operations.

* docs(cloudflare): point self_hosted_domains at its replacement

Cloudflare deprecated the field in favour of destinations, which the tools
already surface. The output stays — Cloudflare still returns it — but the
description now says which one to read.

* refactor(cloudflare): drop a dead exception from the empty-type guard

create_dns_record now takes its record type from the recordType control,
whose dropdown has no empty option, so the operation can never reach this
guard with an empty type. Clear it unconditionally.

* fix(cloudflare): point the canvas sentences at the renamed filter controls

The list filters that were split off their write-side twin kept their old ids
in canvasPresentation, so seven clauses referenced a control that is no longer
visible for that operation — check:canvas-sentences catches exactly this, and
a broken clause fails silently on the card rather than throwing.

* fix(cloudflare): stop the rate limiting action defaulting on a replacing update

Making action required on update_rate_limit_rule was only half the fix: the
Action dropdown still seeded block for the update operation too, so an update
that edited only the threshold kept sending block and converted a live log or
challenge rule into a hard block — exactly the harm the required flag was
meant to prevent. The update now has its own control with no seeded value, so
the action is something the caller states rather than inherits.

The certificate status filter also still offered Active and Pending, which
Cloudflare does not document for that endpoint; the only documented value is
all, and omitting it returns active packs.

* fix(cloudflare): stop the Access replacements seeding a type and a decision

Same class as the rate limiting action: both Access updates are full
replacements, and the shared controls seeded self_hosted and allow for the
update operations too. Editing only a policy's include rules would silently
convert a live deny, bypass, or non_identity policy to allow — widening who
gets in — and editing an application would rewrite what it IS.

Each update now has its own required control with no seeded value, so the
type and the decision are stated rather than inherited. Regression tests
cover both, and the canvas sentence follows the renamed decision control.
2026-08-15 18:50:05 -07:00
Waleed cabd2e2fc1 feat(datadog): extend to 40 tools and align every operation with the published OpenAPI specs (#6745)
* feat(datadog): add incidents, SLOs, dashboards, synthetics, Cloud SIEM, and APM tools

Extends the Datadog block from 12 to 39 operations, all verified against
Datadog's published OpenAPI specs:

- Incidents (v2, public beta): list, get, create, update, add todo
- SLOs (v1): list, get, create, update, delete, history
- Dashboards (v1): list, get, create, delete
- Synthetics (v1): list tests, get test, latest results, trigger, pause/resume
- Cloud SIEM (v2): search signals, get signal, update triage state, assign,
  list detection rules
- APM: search spans (v2), list Service Catalog definitions (v2)

Adds tools/datadog/utils.ts so every tool builds its URL from the configured
site/region and shares the JSON:API-aware error extraction, and handles the
v1 flat vs v2 envelope shapes and cursor pagination per endpoint.

* fix(datadog): align every operation with the published OpenAPI specs

Validated all 39 shipped operations (plus the 12 pre-existing ones that had
never been spec-checked) against the DataDog v1 and v2 OpenAPI schemas.

- `POST /api/v2/downtime` requires `monitor_identifier`, so a downtime created
  without a monitor id was rejected. Default to the `*` monitor tag.
- A one-time downtime schedule declares `additionalProperties: false` and
  accepts only `start`/`end`; the timezone moves to `display_timezone`.
- `GET /api/v2/downtime` has no `monitor_id` filter, and the response carries
  no `disabled` attribute. Downtime ids are UUID strings, not numbers.
- Drop scaffold types for operations that do not exist (metric metadata, event
  query, monitor update/delete/unmute, host listing) along with their fields.
- Note that monitor mute is no longer published in the v1 specification.
- Add browser Synthetic test results, which the browser-specific endpoint
  returns with its own camelCase step-count shape.
- Replace every `any` with a spec-derived interface, keeping the polymorphic
  service-definition schema opaque.

* fix(datadog): remove remaining any types and declare every returned output field

Replace the six surviving `Record<string, any>` request-body and response-cast
sites with concrete spec-derived shapes, and declare the output fields that
transformResponse already returned but outputs omitted:

- create_downtime / list_downtimes: timezone, created, modified
- create_monitor / get_monitor: options, creator
- list_monitors: message, priority, options, created, modified, creator
- query_logs: content.attributes, content.tags
- update_security_signal_state / _assignee: type; assignee also gained the
  archiveReason/archiveComment pair its sibling already declared
- query_timeseries: series gained the items shape it never described

* fix(datadog): stop dropping downtime targeting inputs in the block mapping

create_downtime accepts monitorTags, timezone and muteFirstRecoveryNotification,
but the block exposed no inputs for them and never forwarded them. Monitor-tag
targeting silently fell back to the `*` tag, so a downtime meant for one team's
monitors muted every monitor in scope. Adds the three advanced sub-blocks and
wires them through.

Also routes list_downtimes' currentOnly through toSwitchBoolean. A switch yields
the strings 'true'/'false', and 'false' is truthy, so turning the toggle off
still sent current_only=true. Every other switch in the block already used the
helper; this was the last raw one.

* fix(datadog): correct metric type codes, stop SLO update data loss, drop unpublished mute

Independent re-validation of all 39 operations against the DataDog/datadog-api-client-go
generator specs (v1 and v2 openapi.yaml) rather than the client-rendered docs site.

Correctness:
- submit_metrics sent inverted MetricIntakeType codes (gauge as 0/unspecified, rate as 1/count,
  count as 2/rate), silently changing how Datadog aggregated every submitted series. The spec
  enum is 0 unspecified, 1 count, 2 rate, 3 gauge; an unrecognized type is now omitted so
  Datadog infers it. Also stops stamping an invented `resources: [{name:'host'}]` default and
  now forwards `interval`, which Datadog requires for count and rate metrics.
- update_slo replaced the whole SLO with only the fields the caller filled in, so editing one
  field erased description, tags, query, monitor_ids, groups, thresholds, and timeframe.
  PUT /api/v1/slo/{slo_id} is a full replacement, so the stored SLO is now read first and the
  supplied edits are overlaid onto it, with the read-only fields stripped.
- update_incident admitted empty strings, so a blank input could blank a stored incident title
  or fail as an invalid date-time.
- query_timeseries reported a failed query as success: Datadog returns 200 with a non-ok
  `status` and the reason in `error`.
- create_monitor swallowed malformed options JSON and created a monitor with no thresholds.
- send_logs rebuilt each entry from a fixed field list, discarding the custom attributes
  Datadog accepts as additionalProperties, and padded absent optional fields with empty strings.

Removed:
- mute_monitor. /api/v1/monitor/{monitor_id}/mute is absent from the v1 spec entirely, there is
  no unmute counterpart to reverse it, and downtimes are the supported mechanism.

Contract accuracy:
- Security signal search advertised relative times ("now-1h"); the spec types filter.from/to as
  format: date-time. Descriptions, placeholders, and wand prompts now produce ISO-8601.
- list_incidents advertised an `include` value ("integrations") that is not in the spec enum,
  and neither incident tool trimmed the comma-separated list, so "users, attachments" 400d.
- Invalid "ok" group state dropped from both monitor descriptions.
- time_slice removed from SLO create input, which cannot build one without an SLI specification.
- DatadogSite gains ap2, uk1, and us2.ddog-gov.com.

Pagination and errors:
- list_downtimes silently truncated at Datadog's default 30 with no way to page; adds
  page[limit]/page[offset] and surfaces totalCount.
- query_logs returned a cursor it had no way to accept back.
- Error extraction consolidated onto datadogErrorMessage, which now also reads the
  dictionary-shaped errors of the SLO delete conflict. Ten tools were reading `.detail` off
  plain strings or the raw entry off objects, degrading every failure to a bare status line.
- Debug logging removed from list_monitors.

Adds 29 regression tests, each verified to fail when its fix is reverted.

* fix(datadog): add SEV-0, document page-size caps, drop unsourced output defaults

- The severity dropdown omitted SEV-0, which IncidentSeverity allows and both incident
  tool descriptions already advertised.
- Page-size descriptions now state Datadog's documented default of 10 and cap of 100
  instead of an arbitrary example, so an agent does not request an out-of-range page.
- trigger_synthetics_tests emitted an explicit null for a string-typed optional output,
  and update_synthetics_status reported 'live' on the error path regardless of what the
  caller actually requested.

* fix(datadog): keep mute_monitor and add the missing unmute counterpart

Reverses the removal in the previous commit. Absence from the datadog-api-client-go
generator spec showed the endpoint is unpublished there, not that it is retired:
Datadog's official Python client still implements it on master as
`Monitor.mute(id, scope=, end=)` and `Monitor.unmute(id, scope=, all_scopes=)`
(datadogpy datadog/api/monitors.py), which `_trigger_class_action` resolves to
`POST /api/v1/monitor/{id}/mute` and `/unmute` with exactly those body fields.

mute_monitor has also been in the block since #2175 in December, so dropping it would
have broken existing workflows for an endpoint that two independent sources agree is live.

The genuine defect was that muting was a one-way trapdoor: Sim could mute a monitor but
had no way to reverse it. Adds datadog_unmute_monitor, sharing the monitor ID and scope
inputs with mute, so the operation is recoverable from the same block.

Also: mute no longer discards the response body (it now reports the monitor id, name, and
state), routes errors through datadogErrorMessage, encodes the monitor ID in the path, and
stops dropping an explicit `end` of 0.

* fix(datadog): make downtime targeting explicit and reach downtime pagination from the block

Addresses the review findings on the previous round.

- create_downtime accepted both a monitor ID and monitor tags but `monitor_identifier` is a
  oneOf, so it silently kept the ID and dropped the tags, muting a different set of monitors
  than the caller asked for. It now rejects the ambiguous combination.
- create_downtime ran Number.parseInt on the monitor ID with no validation, so a non-numeric
  value became NaN and serialized as null inside monitor_identifier. It now uses the same
  parseMonitorIds guard the SLO path already had, naming the offending value.
- list_downtimes gained limit/offset in the tool but the block exposed neither, so no
  block-driven call could page past Datadog's default. Adds the two sub-blocks and wires them
  through the params mapper.
- The block did not declare the totalCount the tool now returns, so nothing downstream could
  bind to it.

* fix(datadog): tolerate non-string list inputs and keep the shipped mute subblock ids

Both defects were introduced by this branch.

- splitCommaList called .split on its argument, so routing create_downtime's monitorId
  through it turned a legitimate numeric input into a TypeError before the request was
  built. A <Block.output> reference to get_monitor or list_monitors resolves to a number,
  and an LLM tool call can pass a number or an array, so the helper now normalizes all
  three shapes. The previous Number.parseInt path had accepted a number by coercion.
- Adding the unmute operation renamed the mute subblock ids scope/end to muteScope/muteEnd.
  Workflow state is persisted by subblock id, so every existing Mute Monitor block would
  have kept the old keys and silently lost its scope and end time. Restored the shipped ids;
  both are still unique block-wide and no operation reads another operation's value.

* fix(datadog): compare downtime targets after parsing, not before

A whitespace-only Monitor ID is truthy as a raw string but parses to no monitor, so
the oneOf conflict guard rejected a valid tag-targeted downtime whenever the untouched
Monitor ID field carried blank text. Both sides are now compared after parsing.
2026-08-15 18:48:46 -07:00
Vikhyath Mondreti be20df9257 fix(forking): hide satisfied dependent configuration (#6723)
* fix(forking): hide satisfied dependent configuration

* fix(forking): keep dependent chains configurable

* fix(forking): invalidate stale dependent selectors
2026-08-15 18:36:53 -07:00
Waleed 9e67655b23 feat(servicenow): semantic incident, change, catalog, approval, CMDB, and knowledge tools (#6747)
* feat(servicenow): add semantic incident, change, catalog, approval, CMDB, knowledge, and directory tools

The ServiceNow block only exposed generic Table API CRUD, so every real task
started with "which table is that on?". This adds 27 semantic tools that wrap
the same Table API plumbing under the names customers actually use.

- Incidents: create, get by number or sys_id, search, update, resolve, close,
  and append a work note or customer-visible comment.
- Change: create, get, list, update, move state, and list change tasks through
  the documented Change Management API.
- Service catalog: browse items, order one via the Service Catalog API
  order_now endpoint, and list or get requested items.
- Approvals: list pending approvals for an approver, approve, and reject.
- CMDB: search CIs on any class, read a CI with its inbound and outbound
  relations through the CMDB Instance API, and list cmdb_rel_ci rows.
- Knowledge: search and read articles through the Knowledge Management API.
- Directory: find a user by email or user name and list group members, which
  is what fills assigned_to and assignment_group.

Reference fields are the usual source of confusion, so every semantic read
defaults to sysparm_display_value=all — a reference comes back as both its
sys_id and its label — and every semantic write exposes
sysparm_input_display_value so a display name can be written instead of a
sys_id. Coded state values are exposed as labelled dropdowns built from one
constants module rather than raw integers.

The shared instance-URL, Basic Auth, sysparm, envelope, and error handling now
live in tools/servicenow/utils.ts, and the existing eight generic tools were
moved onto it rather than keeping their own copies.

* fix(servicenow): stop per-operation subblock defaults colliding on a shared id

Subblock initial values are seeded into block state keyed by subblock id, so
two subblocks sharing an id leave one stored value and the last definition
wins. Three ids were duplicated with differing defaults:

- `displayValue` was defined twice, unset for the generic Table API tools and
  `all` for the semantic ones. The semantic definition won, so a new block set
  to Read Records or Aggregate Records sent `sysparm_display_value=all` — a
  wire change to two already-shipped tools.
- `state` was defined four times. The Approval State definition won, so every
  new block carried `state=requested`, which Create Incident wrote to the
  incident and Move Change State used instead of its own `-5` default.

Give the colliding controls their own ids and map them back to the tool params
per operation, so the generic tools keep their original request shape and each
semantic operation keeps its own default.

Also correct descriptions that overstated what the API does: the LIKE operator
is not documented as case-sensitive, List Requested Items has no requester
filter, and the Change Management API task shape differs from the Table API.

Adds tool tests covering the refactor invariants for the eight pre-existing
Table API tools and the display-value separation.

* feat(servicenow): read a change request's real next states from the instance

The change tools describe state transitions using the base-system codes, which
only hold on an instance that has not customized its change model. ServiceNow
publishes an endpoint that answers the question directly for the record in
hand, so use it rather than keep assuming.

GET /api/sn_chg_rest/change/{sys_id}/nextstates returns the states reachable
from the change request, the instance's own state-value-to-label map, and, for
model-driven changes, each transition with the conditions it has and has not
met. The tool flattens the per-target-state grouping ServiceNow returns (each
transition already carries from_state and to_state, so nothing is lost) and
derives the states whose conditions currently pass.

Also record the sourcing for the coded values in constants.ts: the change
states and close codes are published as a table, but the incident state codes
are not — only 6 (Resolved) appears in the docs — so mark the rest as defaults
rather than guarantees. Note that sysparm_input_display_value also reinterprets
date and time values in the caller's timezone instead of GMT, which matters for
the change start and end dates.

* docs(servicenow): stop asserting undocumented coded values in placeholders

The additional-fields examples used hold_reason with a coded value of "1".
ServiceNow documents the On hold reason choices by label only — Awaiting
Caller, Awaiting Change, Awaiting Problem, Awaiting Vendor — and publishes
neither the column name nor the codes, so the example was asserting something
unsourced. Use a field whose value is caller-supplied instead, and record the
On Hold requirement on the incident state control using the labels the docs
actually give, including that Awaiting Caller makes Additional Comments
mandatory.

* fix(servicenow): drop phantom parent fields from the catalog order output

order_catalog_item read parent_id and parent_table off the order_now response.
Those fields belong to submit_producer, a different Service Catalog endpoint;
the documented order_now result is sys_id, number, request_number, request_id,
and table. Both outputs were therefore always null.

* fix(servicenow): correct what knowledge search returns as an article id

Search results carry a table-prefixed identifier — "kb_knowledge:9e528db1..."
— not a bare sys_id, while GET /knowledge/articles/{id} accepts only a bare
sys_id or a KB number. The output described it as a sys_id and the tool
description told callers it was what they needed to fetch the article, so
chaining the two tools on that field would fail. Point callers at the KB
number instead. Relevancy score is documented as a number, not a string.

* docs(servicenow): cite the page that actually documents approval statuses

The approval state constants pointed at the classic-approvals landing page,
which does not list the statuses. Approval status is documented separately and
names four — Requested, Approved, Rejected, and Not Requested.

* fix(servicenow): stop constant interpolation leaking into tool descriptions

The docs generator and the client-facing integration catalog read tool
descriptions from source rather than from the evaluated module, so a
template literal like `state ${INCIDENT_STATE.RESOLVED}` shipped to users
verbatim: `apps/sim/lib/integrations/integrations.json` and the published
ServiceNow integration page both rendered `${INCIDENT_STATE.RESOLVED}`
instead of `6`. Inline the base-system coded values in the description
text; the constants stay in use everywhere behavior depends on them.

Also drops an escaped `\'` in the `inputDisplayValue` description for the
same reason, and adds a standing guard test asserting no subBlock id
carries two different seeded defaults — the invariant behind the
per-operation defaulting bug, now checked structurally rather than only
through the four per-operation cases.

* refactor(servicenow): type the shared response boundary instead of any

`parseServiceNowResponse` returned `any`, so every tool reading `data.result`
did unchecked property access — a shape change on the instance side would have
produced a wrong-typed output silently rather than a type error.

Introduces `ServiceNowEnvelope` (`result?: unknown`) as the parser's return
type and narrows the record index signatures from `any` to `unknown`. Adds
`toRecordObject`, `readString`, and `readNestedNumber` so the tools that read
individual fields narrow deliberately at the point of use.

This surfaced five genuinely unchecked reads: Order Catalog Item, Get Knowledge
Article, and Search Knowledge were declaring `string | null` / `number | null`
outputs while emitting whatever the instance sent, and Get Change Next States
assigned an unvalidated object to `Record<string, string>`. Each now coerces or
drops a non-matching value rather than passing it through.

* fix(servicenow): publish the shared tool params and stop offering inert controls

The docs generator reads tool source rather than importing it, so the shared
`params.ts` consts the semantic tools spread were dropped from every published
Input table — 27 of 35 ServiceNow tools listed no instance URL, username, or
password at all. Follow a spread into the module it is imported from so those
rows are published; ten other integrations gain the rows they were missing for
the same reason.

Two controls were dead on arrival: Additional Fields was offered on Move Change
State and Add Incident Comment, and neither tool read it. Wire it through the
change transition, which needs it, and drop it from the comment tool, whose body
is exactly one journal field.

Every coded-value control was a select-only dropdown, so a customized instance's
state or close code was unreachable — sharpest on Move Change State, whose
target state is required and whose real codes come from Get Change Next States.
Make them comboboxes.

Also correct two doc claims ServiceNow does not publish (the incident state
citation pointed at a page that does not exist and compares the legacy
incident_state field; closing an incident is not documented as requiring
itil_admin), replace Record<string, any> with checked narrowing that surfaced
two unsound widenings, and document that List Change Tasks returns a fixed
{value, display_value} shape under `tasks` rather than `records`.

* fix(servicenow): stop one subblock id from carrying two value spaces

Subblock values are stored per block keyed by id, so an id reused across
operations keeps its value when the operation changes. Incident and change
shared `state`, and `closeCode`, `closeNotes`, `comments`, and the knowledge
search phrase were each reused for a different value space — so an incident
state could be written onto a change request, an incident close code sent as a
change close code, or an encoded query searched as knowledge text.

Give each value space its own subblock and republish it to the tool param from
the operation that owns it, the way targetState and approvalState already work.
The generic Table API ids stay exactly as they are, since renaming one would
orphan the stored value of every workflow already using those shipped tools.

The previous guard only compared seeded defaults, which is why this class stayed
hidden; the new one asserts against the merged params a tool actually receives.

* fix(servicenow): point the canvas sentences at the renamed subblocks

The split of the colliding subblock ids left the operation sentences anchored on
ids that no longer exist, so those clauses would silently drop from the card.

* fix(servicenow): validate collection members and split the fields projection

toRecordArray cast every member of a successful response, so a null or scalar in
a collection was handed to the next block as a record while the tool reported
success and its declared output said that could not happen. Members that are not
plain objects are now dropped, and knowledge articles and change transitions get
the same narrowing. The two response types that described an unverified inner
shape now say what is actually checked.

The 'fields' subblock also carried two value spaces: a JSON body on Create and
Update Record, a comma-separated projection everywhere else. Operations added
since read a separate returnFields control, so a body can no longer arrive as a
projection or the reverse. The shipped ids are untouched, since renaming one
orphans the stored value of every workflow already using those tools.
2026-08-15 18:33:30 -07:00
Waleed 57611bda18 fix(workflow): derive the webhook URL only where a sub-block shows one (#6758)
`sub-block.tsx` mounts `useWebhookManagement` for every sub-block in the editor
panel, and `getBaseUrl()` throws when NEXT_PUBLIC_APP_URL reads empty, so a
missing deployment value took down the whole workflow route instead of the one
webhook field. The hook already gates its query and store writes on
`useWebhookUrl`; the URL now agrees.
2026-08-15 18:32:41 -07:00