diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index 5b82f1070de..e72dd878bbe 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -131,20 +131,24 @@ service's official documentation or an unambiguous local execution path proves t field is consumed by an AI model. If that cannot be established, preserve existing tool behavior and leave the field unannotated. -- **Ordinary provider/API input:** leave it unchanged. Do not add blanket result sanitization. +- **Ordinary provider/API input:** leave it unchanged. Explicit `{{...}}` references resolve and are + sent with their normal request semantics. A URL, domain, resource ID, control field, or opaque + payload is not model-visible merely because the provider is AI-backed or may process the + referenced resource later. - **Text or structured content consumed by an AI model:** declare `request.modelInput` with `mode: 'project'` and select only the exact model-visible fields. The shared executor replaces activated Sim secrets with canonical `{{NAME}}` labels before request formatting. For nested or JSON-string fields, use a small shared selector plus `applyProjected`; verify that selecting the rebuilt params reproduces the projected selection. -- **Opaque model input sent directly to an external provider** such as a model-read URL or image - payload: declare `request.opaqueModelInput` with `mode: 'reject-resolved-secrets'` and select only - the exact effective value. The shared `executeTool` preflight rejects incomplete or secret-bearing - committed provenance before URL/body formatting or network I/O, preserves safe request bytes, - and sends no provenance metadata to the provider. -- **Opaque model input owned by an authenticated internal route** such as uploaded audio, image, - video, file bytes, or signed URLs: add `privateProvenance` to a projected request, or use - `mode: 'private-provenance'` when there is no textual projection. The route must call +- **Serialized model content sent directly to an external provider:** include the serialized + top-level param in `request.modelInput`. Project the private copy before the existing request + formatter parses it; keep formatter behavior deterministic when a whole-value placeholder is not + valid in the serialized grammar. Do not introduce a second hard-rejection path. +- **Opaque model input owned by an authenticated internal route** such as inline audio, image, + video, or document bytes: add `privateProvenance` to a projected request, or use + `mode: 'private-provenance'` when there is no textual projection. Do not select storage keys, + paths, signed URLs, or ordinary remote URLs as byte provenance; the owning route must authorize + stored bytes independently at model egress. The route must call `validateOpaqueModelInputProvenance` before downloading or sending content to the model and must apply the workspace-file provenance guard before reading a persisted workspace file. - **Sim-owned durable storage or internal execution handoff** that can later enter a workflow/model @@ -160,9 +164,9 @@ Hard rules: - Never substitute secret plaintext into source or serialize plaintext provenance. - Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns transport and strips private metadata from functional results. -- Never attach private provenance to an external URL or to `directExecution`. Use the centralized - `opaqueModelInput` rejection mode for external/direct opaque model inputs, or an authenticated - internal route when encrypted provenance must cross the boundary. +- Never attach private provenance to an external URL or to `directExecution`. Project proven + model-visible external fields with `request.modelInput`; otherwise preserve ordinary request + semantics. Use an authenticated internal route when encrypted provenance must cross the boundary. - Never sanitize arbitrary third-party tool results. Projection applies only to secrets activated by Sim's resolved-secret provenance for that execution/tool call. - Do not add provenance merely because a value is persisted, returned by a tool, or appears in a @@ -173,12 +177,11 @@ Hard rules: provider responses, filenames, URLs, and errors remain unchanged when Sim did not resolve a secret into them. -Add focused tests covering named projection, ordinary identical text without provenance, nested -shape preservation, malformed/incomplete private metadata failing closed, centralized external -opaque rejection before formatting/I/O without byte changes or metadata transport, headerless -legacy requests, and absence of private metadata in the public tool result. For durable sinks, also -cover legacy `NULL` markers, exact-empty new writes, tracked secret writes, stale/missing sidecars, -and scope isolation. +Add focused tests covering named projection, ordinary identical text without provenance, nested and +serialized shape handling, unchanged ordinary external inputs, malformed/incomplete private metadata +failing closed, headerless legacy requests, and absence of private metadata in the public tool result. +For durable sinks, also cover legacy `NULL` markers, exact-empty new writes, tracked secret writes, +stale/missing sidecars, and scope isolation. ## Step 3: Create Block @@ -594,8 +597,8 @@ If creating V2 versions (API-aligned outputs): - [ ] Registered all tools in `tools/registry.ts` - [ ] Ran `bun run tool-metadata:generate` and committed the regenerated artifacts - [ ] Classified every model-visible, opaque, Sim-durable, and internal-execution request field -- [ ] Added shared model-input projection, centralized opaque rejection, or private provenance only - where required +- [ ] Added shared model-input projection or private provenance only where required; ordinary + external resource locators and control inputs retain their request semantics - [ ] Confirmed ordinary third-party tool results are not generically sanitized - [ ] Added provenance compatibility and fail-closed boundary tests where applicable diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index 2e3afe56063..734c03bcd9c 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -150,12 +150,16 @@ export const {serviceName}{Action}Tool: ToolConfig< - Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only when an exact field is proven to cross a Sim model, durable-storage, or internal-execution boundary. - Project AI-consumed text/structured fields with the smallest exact `request.modelInput` selector. -- Reject resolved secrets in opaque model input sent directly to an external provider with - `request.opaqueModelInput`; never attach private metadata to an external URL or `directExecution`. -- For authenticated internal routes, use `privateProvenance` for opaque model input or - `request.secretProvenance` for durable writes and execution handoffs. Authenticate first, validate - the exact selection and scope, strip the private envelope, then import or propagate provenance at - the receiving boundary. Preserve documented headerless legacy behavior. +- Treat URLs, domains, resource IDs, and control fields as ordinary request values unless the exact + field is proven model-visible. For serialized external model content, project the serialized + top-level param through `request.modelInput` before the existing formatter parses it; do not add a + separate hard-rejection mechanism. +- For authenticated internal routes, use `privateProvenance` for actual inline/raw model bytes or + `request.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key, + path, signed URL, or remote URL as provenance for fetched bytes; authorize tracked stored bytes at + the owning model-egress boundary. Authenticate first, validate the exact selection and scope, + strip the private envelope, then import or propagate provenance at the receiving boundary. + Preserve documented headerless legacy behavior. - Never substitute secret plaintext into source, serialize plaintext provenance, hand-roll private headers, or blanket-sanitize tool results. - Add focused tests for named projection, identical unproven public text, malformed/incomplete diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index 3d76c78b674..da5ac1dd984 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -141,21 +141,25 @@ search, extraction, or "AI-powered" marketing terminology. - [ ] AI-consumed text/structured fields use `request.modelInput` with `mode: 'project'` and a minimal exact selector; nested/JSON-string adapters preserve shape through `applyProjected` -- [ ] Opaque AI-consumed values sent directly to an external provider or `directExecution` use - `request.opaqueModelInput` with `mode: 'reject-resolved-secrets'` and an exact effective-value - selector; the central executor rejects incomplete/secret-bearing committed provenance before - formatting or I/O, leaves safe bytes unchanged, and sends no provenance metadata externally -- [ ] Opaque AI-consumed files/bytes/URLs owned by an authenticated internal route use +- [ ] Ordinary external URLs, domains, resource IDs, and control fields retain normal request + semantics unless the exact field is proven model-visible; an AI-backed provider or later model + processing of the referenced resource is not sufficient evidence +- [ ] Serialized content proven to be sent directly to an external model is selected by + `request.modelInput`, projected before the existing formatter parses it, and has deterministic + formatter behavior when a whole-value placeholder is invalid for the serialized grammar +- [ ] Actual inline/raw AI-consumed bytes owned by an authenticated internal route use `privateProvenance` (or `mode: 'private-provenance'`), and the route validates - `validateOpaqueModelInputProvenance` before any download or model call + `validateOpaqueModelInputProvenance` before model egress; storage keys, paths, signed URLs, + and ordinary remote URLs are not treated as byte provenance, while tracked stored bytes are + authorized independently at the owning model-egress boundary - [ ] Persisted workspace-file contents are checked with the shared provenance guard only when their bytes or decoded content cross into a model/tool-result boundary; ordinary file APIs remain unchanged. Unsupported secret-bearing file paths are rejected at `file_write` - [ ] Sim-owned durable writes and internal execution handoffs that can enter workflows/models use field-scoped `request.secretProvenance`; authenticated receivers validate the exact selection and scope, strip private metadata, and persist, import, or propagate it at the owning boundary -- [ ] Private provenance is never attached to external URLs or `directExecution`; those paths use - centralized `opaqueModelInput` rejection when their opaque values are model-bound +- [ ] Private provenance is never attached to external URLs or `directExecution`; proven + model-visible external fields use projection, while other external inputs remain unchanged - [ ] No tool performs raw secret plaintext/source substitution or serializes plaintext provenance - [ ] No `transformResponse` or tool-local helper blanket-sanitizes ordinary third-party results; only execution-scoped, activated Sim provenance is projected at shared model/log boundaries @@ -166,10 +170,9 @@ search, extraction, or "AI-powered" marketing terminology. metadata, provider results, or API payloads - [ ] Diagnostic projection is applied only to values carrying execution-scoped provenance; ordinary provider responses, filenames, URLs, and errors are unchanged -- [ ] Tests cover named `{{NAME}}` projection, unproven identical public text, nested shape - preservation, malformed/incomplete metadata, centralized opaque rejection before formatting - or I/O with safe-byte preservation, headerless legacy requests, metadata stripping, and - durable legacy/stale/scope cases when applicable +- [ ] Tests cover named `{{NAME}}` projection, unproven identical public text, nested and serialized + shape handling, unchanged ordinary external inputs, malformed/incomplete metadata, headerless + legacy requests, metadata stripping, and durable legacy/stale/scope cases when applicable Treat a missing or bypassed model, durable, or internal-execution provenance boundary as **critical**. Do not fix it with a tool-specific string replacer or by sanitizing every provider @@ -348,8 +351,7 @@ Group findings by severity: - Service-account metadata disagrees with the canonical OAuth service configuration - `tools.config.tool` returning wrong tool ID for an operation - Type coercions in `tools.config.tool` instead of `tools.config.params` -- AI-consumed request fields bypass the shared projection, centralized opaque rejection, or - private-provenance boundary +- Proven model-visible request fields bypass the shared projection or private-provenance boundary - Opaque model input is downloaded or sent before provenance and workspace-file checks - A Sim-owned durable sink or internal execution handoff drops encrypted provenance or breaks legacy headerless/`NULL` data diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index 2b8e6a4fc13..864dc9ab9b3 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -130,20 +130,24 @@ service's official documentation or an unambiguous local execution path proves t field is consumed by an AI model. If that cannot be established, preserve existing tool behavior and leave the field unannotated. -- **Ordinary provider/API input:** leave it unchanged. Do not add blanket result sanitization. +- **Ordinary provider/API input:** leave it unchanged. Explicit `{{...}}` references resolve and are + sent with their normal request semantics. A URL, domain, resource ID, control field, or opaque + payload is not model-visible merely because the provider is AI-backed or may process the + referenced resource later. - **Text or structured content consumed by an AI model:** declare `request.modelInput` with `mode: 'project'` and select only the exact model-visible fields. The shared executor replaces activated Sim secrets with canonical `{{NAME}}` labels before request formatting. For nested or JSON-string fields, use a small shared selector plus `applyProjected`; verify that selecting the rebuilt params reproduces the projected selection. -- **Opaque model input sent directly to an external provider** such as a model-read URL or image - payload: declare `request.opaqueModelInput` with `mode: 'reject-resolved-secrets'` and select only - the exact effective value. The shared `executeTool` preflight rejects incomplete or secret-bearing - committed provenance before URL/body formatting or network I/O, preserves safe request bytes, - and sends no provenance metadata to the provider. -- **Opaque model input owned by an authenticated internal route** such as uploaded audio, image, - video, file bytes, or signed URLs: add `privateProvenance` to a projected request, or use - `mode: 'private-provenance'` when there is no textual projection. The route must call +- **Serialized model content sent directly to an external provider:** include the serialized + top-level param in `request.modelInput`. Project the private copy before the existing request + formatter parses it; keep formatter behavior deterministic when a whole-value placeholder is not + valid in the serialized grammar. Do not introduce a second hard-rejection path. +- **Opaque model input owned by an authenticated internal route** such as inline audio, image, + video, or document bytes: add `privateProvenance` to a projected request, or use + `mode: 'private-provenance'` when there is no textual projection. Do not select storage keys, + paths, signed URLs, or ordinary remote URLs as byte provenance; the owning route must authorize + stored bytes independently at model egress. The route must call `validateOpaqueModelInputProvenance` before downloading or sending content to the model and must apply the workspace-file provenance guard before reading a persisted workspace file. - **Sim-owned durable storage or internal execution handoff** that can later enter a workflow/model @@ -159,9 +163,9 @@ Hard rules: - Never substitute secret plaintext into source or serialize plaintext provenance. - Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns transport and strips private metadata from functional results. -- Never attach private provenance to an external URL or to `directExecution`. Use the centralized - `opaqueModelInput` rejection mode for external/direct opaque model inputs, or an authenticated - internal route when encrypted provenance must cross the boundary. +- Never attach private provenance to an external URL or to `directExecution`. Project proven + model-visible external fields with `request.modelInput`; otherwise preserve ordinary request + semantics. Use an authenticated internal route when encrypted provenance must cross the boundary. - Never sanitize arbitrary third-party tool results. Projection applies only to secrets activated by Sim's resolved-secret provenance for that execution/tool call. - Do not add provenance merely because a value is persisted, returned by a tool, or appears in a @@ -172,12 +176,11 @@ Hard rules: provider responses, filenames, URLs, and errors remain unchanged when Sim did not resolve a secret into them. -Add focused tests covering named projection, ordinary identical text without provenance, nested -shape preservation, malformed/incomplete private metadata failing closed, centralized external -opaque rejection before formatting/I/O without byte changes or metadata transport, headerless -legacy requests, and absence of private metadata in the public tool result. For durable sinks, also -cover legacy `NULL` markers, exact-empty new writes, tracked secret writes, stale/missing sidecars, -and scope isolation. +Add focused tests covering named projection, ordinary identical text without provenance, nested and +serialized shape handling, unchanged ordinary external inputs, malformed/incomplete private metadata +failing closed, headerless legacy requests, and absence of private metadata in the public tool result. +For durable sinks, also cover legacy `NULL` markers, exact-empty new writes, tracked secret writes, +stale/missing sidecars, and scope isolation. ## Step 3: Create Block @@ -593,8 +596,8 @@ If creating V2 versions (API-aligned outputs): - [ ] Registered all tools in `tools/registry.ts` - [ ] Ran `bun run tool-metadata:generate` and committed the regenerated artifacts - [ ] Classified every model-visible, opaque, Sim-durable, and internal-execution request field -- [ ] Added shared model-input projection, centralized opaque rejection, or private provenance only - where required +- [ ] Added shared model-input projection or private provenance only where required; ordinary + external resource locators and control inputs retain their request semantics - [ ] Confirmed ordinary third-party tool results are not generically sanitized - [ ] Added provenance compatibility and fail-closed boundary tests where applicable diff --git a/.claude/commands/add-tools.md b/.claude/commands/add-tools.md index e5c0da8997a..6b390520b64 100644 --- a/.claude/commands/add-tools.md +++ b/.claude/commands/add-tools.md @@ -149,12 +149,16 @@ export const {serviceName}{Action}Tool: ToolConfig< - Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only when an exact field is proven to cross a Sim model, durable-storage, or internal-execution boundary. - Project AI-consumed text/structured fields with the smallest exact `request.modelInput` selector. -- Reject resolved secrets in opaque model input sent directly to an external provider with - `request.opaqueModelInput`; never attach private metadata to an external URL or `directExecution`. -- For authenticated internal routes, use `privateProvenance` for opaque model input or - `request.secretProvenance` for durable writes and execution handoffs. Authenticate first, validate - the exact selection and scope, strip the private envelope, then import or propagate provenance at - the receiving boundary. Preserve documented headerless legacy behavior. +- Treat URLs, domains, resource IDs, and control fields as ordinary request values unless the exact + field is proven model-visible. For serialized external model content, project the serialized + top-level param through `request.modelInput` before the existing formatter parses it; do not add a + separate hard-rejection mechanism. +- For authenticated internal routes, use `privateProvenance` for actual inline/raw model bytes or + `request.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key, + path, signed URL, or remote URL as provenance for fetched bytes; authorize tracked stored bytes at + the owning model-egress boundary. Authenticate first, validate the exact selection and scope, + strip the private envelope, then import or propagate provenance at the receiving boundary. + Preserve documented headerless legacy behavior. - Never substitute secret plaintext into source, serialize plaintext provenance, hand-roll private headers, or blanket-sanitize tool results. - Add focused tests for named projection, identical unproven public text, malformed/incomplete diff --git a/.claude/commands/validate-integration.md b/.claude/commands/validate-integration.md index 2c343a8fb65..79276796280 100644 --- a/.claude/commands/validate-integration.md +++ b/.claude/commands/validate-integration.md @@ -140,21 +140,25 @@ search, extraction, or "AI-powered" marketing terminology. - [ ] AI-consumed text/structured fields use `request.modelInput` with `mode: 'project'` and a minimal exact selector; nested/JSON-string adapters preserve shape through `applyProjected` -- [ ] Opaque AI-consumed values sent directly to an external provider or `directExecution` use - `request.opaqueModelInput` with `mode: 'reject-resolved-secrets'` and an exact effective-value - selector; the central executor rejects incomplete/secret-bearing committed provenance before - formatting or I/O, leaves safe bytes unchanged, and sends no provenance metadata externally -- [ ] Opaque AI-consumed files/bytes/URLs owned by an authenticated internal route use +- [ ] Ordinary external URLs, domains, resource IDs, and control fields retain normal request + semantics unless the exact field is proven model-visible; an AI-backed provider or later model + processing of the referenced resource is not sufficient evidence +- [ ] Serialized content proven to be sent directly to an external model is selected by + `request.modelInput`, projected before the existing formatter parses it, and has deterministic + formatter behavior when a whole-value placeholder is invalid for the serialized grammar +- [ ] Actual inline/raw AI-consumed bytes owned by an authenticated internal route use `privateProvenance` (or `mode: 'private-provenance'`), and the route validates - `validateOpaqueModelInputProvenance` before any download or model call + `validateOpaqueModelInputProvenance` before model egress; storage keys, paths, signed URLs, + and ordinary remote URLs are not treated as byte provenance, while tracked stored bytes are + authorized independently at the owning model-egress boundary - [ ] Persisted workspace-file contents are checked with the shared provenance guard only when their bytes or decoded content cross into a model/tool-result boundary; ordinary file APIs remain unchanged. Unsupported secret-bearing file paths are rejected at `file_write` - [ ] Sim-owned durable writes and internal execution handoffs that can enter workflows/models use field-scoped `request.secretProvenance`; authenticated receivers validate the exact selection and scope, strip private metadata, and persist, import, or propagate it at the owning boundary -- [ ] Private provenance is never attached to external URLs or `directExecution`; those paths use - centralized `opaqueModelInput` rejection when their opaque values are model-bound +- [ ] Private provenance is never attached to external URLs or `directExecution`; proven + model-visible external fields use projection, while other external inputs remain unchanged - [ ] No tool performs raw secret plaintext/source substitution or serializes plaintext provenance - [ ] No `transformResponse` or tool-local helper blanket-sanitizes ordinary third-party results; only execution-scoped, activated Sim provenance is projected at shared model/log boundaries @@ -165,10 +169,9 @@ search, extraction, or "AI-powered" marketing terminology. metadata, provider results, or API payloads - [ ] Diagnostic projection is applied only to values carrying execution-scoped provenance; ordinary provider responses, filenames, URLs, and errors are unchanged -- [ ] Tests cover named `{{NAME}}` projection, unproven identical public text, nested shape - preservation, malformed/incomplete metadata, centralized opaque rejection before formatting - or I/O with safe-byte preservation, headerless legacy requests, metadata stripping, and - durable legacy/stale/scope cases when applicable +- [ ] Tests cover named `{{NAME}}` projection, unproven identical public text, nested and serialized + shape handling, unchanged ordinary external inputs, malformed/incomplete metadata, headerless + legacy requests, metadata stripping, and durable legacy/stale/scope cases when applicable Treat a missing or bypassed model, durable, or internal-execution provenance boundary as **critical**. Do not fix it with a tool-specific string replacer or by sanitizing every provider @@ -347,8 +350,7 @@ Group findings by severity: - Service-account metadata disagrees with the canonical OAuth service configuration - `tools.config.tool` returning wrong tool ID for an operation - Type coercions in `tools.config.tool` instead of `tools.config.params` -- AI-consumed request fields bypass the shared projection, centralized opaque rejection, or - private-provenance boundary +- Proven model-visible request fields bypass the shared projection or private-provenance boundary - Opaque model input is downloaded or sent before provenance and workspace-file checks - A Sim-owned durable sink or internal execution handoff drops encrypted provenance or breaks legacy headerless/`NULL` data diff --git a/.claude/rules/sim-list-ordering.md b/.claude/rules/sim-list-ordering.md new file mode 100644 index 00000000000..2966eb4a1a6 --- /dev/null +++ b/.claude/rules/sim-list-ordering.md @@ -0,0 +1,76 @@ +--- +paths: + - "apps/sim/app/**/*.tsx" + - "apps/sim/ee/**/*.tsx" + - "apps/sim/components/**/*.tsx" +--- + +# List & Menu Ordering + +**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in the sidebar, in a toolbar, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time. + +This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right. + +## The rule + +Before writing a list of items, find where the user sees those same items *first*. That surface owns the order; your list mirrors it. + +| The list | Mirrors | +| --- | --- | +| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down | +| A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom | +| Settings tab strip, recently-deleted tabs | the **settings nav**, top-down | +| A "New …" menu | the order those things appear once created | + +Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export · Delete` becomes a menu reading Filter, Sort, Export, Delete — never alphabetized, never grouped by implementation, never "destructive last" unless the toolbar already puts it last. + +Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform. + +## Encode the order once + +An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu. + +```ts +/** Top-down order for every menu listing resource families, mirroring the sidebar. */ +export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ + 'integration', 'task', 'table', 'file', 'filefolder', + 'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic', +] + +export function byResourceMenuOrder(a: T, b: T) { + return RESOURCE_MENU_ORDER.indexOf(a.type) - RESOURCE_MENU_ORDER.indexOf(b.type) +} +``` + +Canonical instance: `app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx`, consumed by `useAvailableResources` and `ResourceMenuSections`. + +## Render kinds in one pass, not one phase per kind + +The most common way a canonical order gets silently defeated: emitting all items of one *kind* and then all of another. Every submenu-backed family lands above every flat family regardless of what the order constant says. + +```tsx +// ✗ Bad — two phases; the trees always pin to the top + +{groups.filter((g) => !FOLDERED.has(g.type)).map(renderFlat)} + +// ✓ Good — one ordered pass; each entry picks its own rendering +{entries.sort(byResourceMenuOrder).map((entry) => + sectionByType.has(entry.type) ? renderTree(entry) : renderFlat(entry) +)} +``` + +The same trap appears as "render the pinned ones, then the rest", "render enabled, then disabled", and "render the groups, then the loose items". + +## When order may diverge + +Only for reasons the user can perceive: + +- **Search/filter results** rank by match quality — the whole point is that ranking beats position. +- **User-controlled ordering** (drag-to-reorder, manual `sortOrder`) wins over any canonical order. +- **Recency lists** ("Recent chats") order by time, which *is* the order the user reads them elsewhere. + +"Grouped by which hook provides it", "alphabetical because it was easy", and "that's the order the array was built in" are not reasons. + +## Reviewing + +When a diff adds or edits a list of items, ask: where does the user see this set already, and does this match? If the answer is a different file with a different order, the diff needs a shared constant, not a second literal. diff --git a/.cursor/commands/add-integration.md b/.cursor/commands/add-integration.md index 9c5498257b6..40cc28d8b8f 100644 --- a/.cursor/commands/add-integration.md +++ b/.cursor/commands/add-integration.md @@ -125,20 +125,24 @@ service's official documentation or an unambiguous local execution path proves t field is consumed by an AI model. If that cannot be established, preserve existing tool behavior and leave the field unannotated. -- **Ordinary provider/API input:** leave it unchanged. Do not add blanket result sanitization. +- **Ordinary provider/API input:** leave it unchanged. Explicit `{{...}}` references resolve and are + sent with their normal request semantics. A URL, domain, resource ID, control field, or opaque + payload is not model-visible merely because the provider is AI-backed or may process the + referenced resource later. - **Text or structured content consumed by an AI model:** declare `request.modelInput` with `mode: 'project'` and select only the exact model-visible fields. The shared executor replaces activated Sim secrets with canonical `{{NAME}}` labels before request formatting. For nested or JSON-string fields, use a small shared selector plus `applyProjected`; verify that selecting the rebuilt params reproduces the projected selection. -- **Opaque model input sent directly to an external provider** such as a model-read URL or image - payload: declare `request.opaqueModelInput` with `mode: 'reject-resolved-secrets'` and select only - the exact effective value. The shared `executeTool` preflight rejects incomplete or secret-bearing - committed provenance before URL/body formatting or network I/O, preserves safe request bytes, - and sends no provenance metadata to the provider. -- **Opaque model input owned by an authenticated internal route** such as uploaded audio, image, - video, file bytes, or signed URLs: add `privateProvenance` to a projected request, or use - `mode: 'private-provenance'` when there is no textual projection. The route must call +- **Serialized model content sent directly to an external provider:** include the serialized + top-level param in `request.modelInput`. Project the private copy before the existing request + formatter parses it; keep formatter behavior deterministic when a whole-value placeholder is not + valid in the serialized grammar. Do not introduce a second hard-rejection path. +- **Opaque model input owned by an authenticated internal route** such as inline audio, image, + video, or document bytes: add `privateProvenance` to a projected request, or use + `mode: 'private-provenance'` when there is no textual projection. Do not select storage keys, + paths, signed URLs, or ordinary remote URLs as byte provenance; the owning route must authorize + stored bytes independently at model egress. The route must call `validateOpaqueModelInputProvenance` before downloading or sending content to the model and must apply the workspace-file provenance guard before reading a persisted workspace file. - **Sim-owned durable storage or internal execution handoff** that can later enter a workflow/model @@ -154,9 +158,9 @@ Hard rules: - Never substitute secret plaintext into source or serialize plaintext provenance. - Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns transport and strips private metadata from functional results. -- Never attach private provenance to an external URL or to `directExecution`. Use the centralized - `opaqueModelInput` rejection mode for external/direct opaque model inputs, or an authenticated - internal route when encrypted provenance must cross the boundary. +- Never attach private provenance to an external URL or to `directExecution`. Project proven + model-visible external fields with `request.modelInput`; otherwise preserve ordinary request + semantics. Use an authenticated internal route when encrypted provenance must cross the boundary. - Never sanitize arbitrary third-party tool results. Projection applies only to secrets activated by Sim's resolved-secret provenance for that execution/tool call. - Do not add provenance merely because a value is persisted, returned by a tool, or appears in a @@ -167,12 +171,11 @@ Hard rules: provider responses, filenames, URLs, and errors remain unchanged when Sim did not resolve a secret into them. -Add focused tests covering named projection, ordinary identical text without provenance, nested -shape preservation, malformed/incomplete private metadata failing closed, centralized external -opaque rejection before formatting/I/O without byte changes or metadata transport, headerless -legacy requests, and absence of private metadata in the public tool result. For durable sinks, also -cover legacy `NULL` markers, exact-empty new writes, tracked secret writes, stale/missing sidecars, -and scope isolation. +Add focused tests covering named projection, ordinary identical text without provenance, nested and +serialized shape handling, unchanged ordinary external inputs, malformed/incomplete private metadata +failing closed, headerless legacy requests, and absence of private metadata in the public tool result. +For durable sinks, also cover legacy `NULL` markers, exact-empty new writes, tracked secret writes, +stale/missing sidecars, and scope isolation. ## Step 3: Create Block @@ -588,8 +591,8 @@ If creating V2 versions (API-aligned outputs): - [ ] Registered all tools in `tools/registry.ts` - [ ] Ran `bun run tool-metadata:generate` and committed the regenerated artifacts - [ ] Classified every model-visible, opaque, Sim-durable, and internal-execution request field -- [ ] Added shared model-input projection, centralized opaque rejection, or private provenance only - where required +- [ ] Added shared model-input projection or private provenance only where required; ordinary + external resource locators and control inputs retain their request semantics - [ ] Confirmed ordinary third-party tool results are not generically sanitized - [ ] Added provenance compatibility and fail-closed boundary tests where applicable diff --git a/.cursor/commands/add-tools.md b/.cursor/commands/add-tools.md index 45399b10698..c8611887dd8 100644 --- a/.cursor/commands/add-tools.md +++ b/.cursor/commands/add-tools.md @@ -144,12 +144,16 @@ export const {serviceName}{Action}Tool: ToolConfig< - Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only when an exact field is proven to cross a Sim model, durable-storage, or internal-execution boundary. - Project AI-consumed text/structured fields with the smallest exact `request.modelInput` selector. -- Reject resolved secrets in opaque model input sent directly to an external provider with - `request.opaqueModelInput`; never attach private metadata to an external URL or `directExecution`. -- For authenticated internal routes, use `privateProvenance` for opaque model input or - `request.secretProvenance` for durable writes and execution handoffs. Authenticate first, validate - the exact selection and scope, strip the private envelope, then import or propagate provenance at - the receiving boundary. Preserve documented headerless legacy behavior. +- Treat URLs, domains, resource IDs, and control fields as ordinary request values unless the exact + field is proven model-visible. For serialized external model content, project the serialized + top-level param through `request.modelInput` before the existing formatter parses it; do not add a + separate hard-rejection mechanism. +- For authenticated internal routes, use `privateProvenance` for actual inline/raw model bytes or + `request.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key, + path, signed URL, or remote URL as provenance for fetched bytes; authorize tracked stored bytes at + the owning model-egress boundary. Authenticate first, validate the exact selection and scope, + strip the private envelope, then import or propagate provenance at the receiving boundary. + Preserve documented headerless legacy behavior. - Never substitute secret plaintext into source, serialize plaintext provenance, hand-roll private headers, or blanket-sanitize tool results. - Add focused tests for named projection, identical unproven public text, malformed/incomplete diff --git a/.cursor/commands/validate-integration.md b/.cursor/commands/validate-integration.md index 4ec7b32e9ec..0c08276a7f1 100644 --- a/.cursor/commands/validate-integration.md +++ b/.cursor/commands/validate-integration.md @@ -135,21 +135,25 @@ search, extraction, or "AI-powered" marketing terminology. - [ ] AI-consumed text/structured fields use `request.modelInput` with `mode: 'project'` and a minimal exact selector; nested/JSON-string adapters preserve shape through `applyProjected` -- [ ] Opaque AI-consumed values sent directly to an external provider or `directExecution` use - `request.opaqueModelInput` with `mode: 'reject-resolved-secrets'` and an exact effective-value - selector; the central executor rejects incomplete/secret-bearing committed provenance before - formatting or I/O, leaves safe bytes unchanged, and sends no provenance metadata externally -- [ ] Opaque AI-consumed files/bytes/URLs owned by an authenticated internal route use +- [ ] Ordinary external URLs, domains, resource IDs, and control fields retain normal request + semantics unless the exact field is proven model-visible; an AI-backed provider or later model + processing of the referenced resource is not sufficient evidence +- [ ] Serialized content proven to be sent directly to an external model is selected by + `request.modelInput`, projected before the existing formatter parses it, and has deterministic + formatter behavior when a whole-value placeholder is invalid for the serialized grammar +- [ ] Actual inline/raw AI-consumed bytes owned by an authenticated internal route use `privateProvenance` (or `mode: 'private-provenance'`), and the route validates - `validateOpaqueModelInputProvenance` before any download or model call + `validateOpaqueModelInputProvenance` before model egress; storage keys, paths, signed URLs, + and ordinary remote URLs are not treated as byte provenance, while tracked stored bytes are + authorized independently at the owning model-egress boundary - [ ] Persisted workspace-file contents are checked with the shared provenance guard only when their bytes or decoded content cross into a model/tool-result boundary; ordinary file APIs remain unchanged. Unsupported secret-bearing file paths are rejected at `file_write` - [ ] Sim-owned durable writes and internal execution handoffs that can enter workflows/models use field-scoped `request.secretProvenance`; authenticated receivers validate the exact selection and scope, strip private metadata, and persist, import, or propagate it at the owning boundary -- [ ] Private provenance is never attached to external URLs or `directExecution`; those paths use - centralized `opaqueModelInput` rejection when their opaque values are model-bound +- [ ] Private provenance is never attached to external URLs or `directExecution`; proven + model-visible external fields use projection, while other external inputs remain unchanged - [ ] No tool performs raw secret plaintext/source substitution or serializes plaintext provenance - [ ] No `transformResponse` or tool-local helper blanket-sanitizes ordinary third-party results; only execution-scoped, activated Sim provenance is projected at shared model/log boundaries @@ -160,10 +164,9 @@ search, extraction, or "AI-powered" marketing terminology. metadata, provider results, or API payloads - [ ] Diagnostic projection is applied only to values carrying execution-scoped provenance; ordinary provider responses, filenames, URLs, and errors are unchanged -- [ ] Tests cover named `{{NAME}}` projection, unproven identical public text, nested shape - preservation, malformed/incomplete metadata, centralized opaque rejection before formatting - or I/O with safe-byte preservation, headerless legacy requests, metadata stripping, and - durable legacy/stale/scope cases when applicable +- [ ] Tests cover named `{{NAME}}` projection, unproven identical public text, nested and serialized + shape handling, unchanged ordinary external inputs, malformed/incomplete metadata, headerless + legacy requests, metadata stripping, and durable legacy/stale/scope cases when applicable Treat a missing or bypassed model, durable, or internal-execution provenance boundary as **critical**. Do not fix it with a tool-specific string replacer or by sanitizing every provider @@ -342,8 +345,7 @@ Group findings by severity: - Service-account metadata disagrees with the canonical OAuth service configuration - `tools.config.tool` returning wrong tool ID for an operation - Type coercions in `tools.config.tool` instead of `tools.config.params` -- AI-consumed request fields bypass the shared projection, centralized opaque rejection, or - private-provenance boundary +- Proven model-visible request fields bypass the shared projection or private-provenance boundary - Opaque model input is downloaded or sent before provenance and workspace-file checks - A Sim-owned durable sink or internal execution handoff drops encrypted provenance or breaks legacy headerless/`NULL` data diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f3b23b10b5d..c7671658080 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -19,6 +19,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here} - COPILOT_API_KEY=${COPILOT_API_KEY} + - MSHIP_SYSPROMPT_OVERRIDE=${MSHIP_SYSPROMPT_OVERRIDE:-} - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 51b709d5330..f44ca430c7c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,13 +41,12 @@ on: # Safety net behind the push trigger, and the thing that keeps the # default-branch alert view fresh when main is quiet. Only fires once this # file is on the default branch — schedule events ignore other branches. - - cron: '17 8 * * *' + - cron: '17 8 * * 1' workflow_dispatch: -# Scheduled main scans must run to completion — only PR pushes supersede. concurrency: group: codeql-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true permissions: contents: read diff --git a/CLAUDE.md b/CLAUDE.md index 1a1e4671737..fc63380d153 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -378,6 +378,12 @@ Shareable *client* view-state (active tab/panel, filters, search query, paginati Co-locate a `search-params.ts` per feature exporting the parser map (single source of truth, shared by client `useQueryStates`/`useQueryState` and server `createSearchParamsCache`). Never `import { z }` in client code for params — use nuqs parsers. Full decision framework, conventions, the debounced-input pattern, and the workflow-editor carve-out are in `.claude/rules/sim-url-state.md`. +## List & Menu Ordering + +A list orders itself the way the user already reads the same things somewhere else. Resource menus (`+` attach, `@` mention, resource-tab `+`) mirror the **sidebar** top-down; a row or root **context menu** mirrors that surface's **toolbar**, left-to-right becoming top-to-bottom; tab strips mirror their nav. Platform-only entries (desktop Browser, Terminal) trail the shared set. + +Encode the order in ONE exported constant and sort by it — never a hand-maintained literal per menu (`RESOURCE_MENU_ORDER` / `byResourceMenuOrder` in `home/components/mothership-view/components/resource-registry`). Render mixed item kinds in a single ordered pass; emitting all submenu-backed families and then all flat ones silently pins every submenu to the top no matter what the constant says. Divergence is allowed only for search ranking, user-controlled ordering, and recency. Full rule in `.claude/rules/sim-list-ordering.md`. + ## Styling Use Tailwind only, no inline styles. Use `cn()` from `@sim/emcn` for conditional classes. diff --git a/apps/desktop/src/main/browser-agent/url-guard.test.ts b/apps/desktop/src/main/browser-agent/url-guard.test.ts index 8544e149b90..e50d51eb6b7 100644 --- a/apps/desktop/src/main/browser-agent/url-guard.test.ts +++ b/apps/desktop/src/main/browser-agent/url-guard.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +// url-guard pulls in @/main/navigation, which imports electron. +vi.mock('electron', () => import('@/test/electron-mock')) + const { mockLookup } = vi.hoisted(() => ({ mockLookup: vi.fn() })) // The real resolveHostAddresses runs; only the resolver under it is mocked, so diff --git a/apps/desktop/src/main/csp.test.ts b/apps/desktop/src/main/csp.test.ts index 1319ca314fb..0bab8851199 100644 --- a/apps/desktop/src/main/csp.test.ts +++ b/apps/desktop/src/main/csp.test.ts @@ -1,4 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' + +// csp pulls in @/main/navigation, which imports electron. +vi.mock('electron', () => import('@/test/electron-mock')) + import { attachCspFallback, DEFAULT_DESKTOP_CSP } from '@/main/csp' type HeadersReceivedHandler = ( diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts index f594bf2b562..12feb6395e8 100644 --- a/apps/desktop/src/main/handoff.test.ts +++ b/apps/desktop/src/main/handoff.test.ts @@ -8,9 +8,11 @@ import { buildRedeemScript, type ConnectHandoffCallback, createAuthFlow, + createConnectFlow, createHandoffManager, type HandoffCallback, type HandoffCallbacks, + type HandoffManager, type HandoffManagerDeps, } from '@/main/handoff' import type { EventRecorder } from '@/main/observability' @@ -241,6 +243,24 @@ describe('createHandoffManager', () => { expect(manager.consume(state, 'login')).toBe(false) expect(manager.consume(state, 'connect')).toBe(true) }) + + it('returns the chat attempt correlated with the accepted connect state', async () => { + const deps = makeDeps() + const manager = createHandoffManager(deps, makeCallbacks()) + await manager.beginConnect('google-email', { + workspaceId: 'workspace-1', + chatAttemptId: 'attempt-1', + }) + const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( + 'state' + ) as string + + expect(manager.consumeConnect(state)).toEqual({ + workspaceId: 'workspace-1', + chatAttemptId: 'attempt-1', + }) + expect(manager.consumeConnect(state)).toBeNull() + }) }) describe('connect handoff account pinning', () => { @@ -273,6 +293,50 @@ describe('connect handoff account pinning', () => { }) }) +describe('connect completion correlation', () => { + function makeConnectManager(scope: { chatAttemptId?: string }): HandoffManager { + return { + begin: vi.fn(async () => true), + beginConnect: vi.fn(async () => true), + consume: vi.fn(() => true), + consumeConnect: vi.fn(() => scope), + clear: vi.fn(), + } + } + + it('echoes the accepted handoff chat attempt to the renderer', () => { + const notifyRenderer = vi.fn() + const flow = createConnectFlow({ + handoff: makeConnectManager({ chatAttemptId: 'attempt-1' }), + events: makeEvents(), + focusMainWindow: vi.fn(), + notifyRenderer, + }) + + flow.handleCallback({ state: VALID_STATE }) + + expect(notifyRenderer).toHaveBeenCalledWith({ ok: true, chatAttemptId: 'attempt-1' }) + }) + + it('marks ordinary integrations-page completions as explicitly uncorrelated', () => { + const notifyRenderer = vi.fn() + const flow = createConnectFlow({ + handoff: makeConnectManager({}), + events: makeEvents(), + focusMainWindow: vi.fn(), + notifyRenderer, + }) + + flow.handleCallback({ state: VALID_STATE, error: 'oauth_failed' }) + + expect(notifyRenderer).toHaveBeenCalledWith({ + ok: false, + error: 'oauth_failed', + chatAttemptId: null, + }) + }) +}) + describe('createAuthFlow window failures', () => { function makeAuthDeps(ensureMainWindow: () => Promise) { const events = makeEvents() diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts index 5d1313ebc56..dc5d15b52e3 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -67,12 +67,14 @@ export interface HandoffManagerDeps { export interface ConnectScope { workspaceId?: string credentialId?: string + chatAttemptId?: string } export interface HandoffManager { begin(): Promise beginConnect(providerId: string, scope?: ConnectScope): Promise consume(state: string, kind: HandoffKind): boolean + consumeConnect(state: string): ConnectScope | null clear(): void } @@ -92,7 +94,12 @@ export function createHandoffManager( const now = deps.now ?? Date.now let loopbackServer: Server | null = null let loopbackTimer: NodeJS.Timeout | undefined - let pending: { state: string; createdAt: number; kind: HandoffKind } | null = null + let pending: { + state: string + createdAt: number + kind: HandoffKind + connectScope?: ConnectScope + } | null = null const stopLoopback = () => { clearTimeout(loopbackTimer) @@ -214,10 +221,23 @@ export function createHandoffManager( pending = null } + const consumePending = (state: string, kind: HandoffKind): NonNullable | null => { + if (!pending || pending.kind !== kind) return null + if (now() - pending.createdAt > HANDOFF_TTL_MS) { + clear() + return null + } + if (!safeCompare(pending.state, state)) return null + const consumed = pending + clear() + return consumed + } + const beginFlow = async ( kind: HandoffKind, landingPath: string, - params: Record + params: Record, + connectScope?: ConnectScope ): Promise => { const state = generateShortId(STATE_LENGTH) // startLoopback() already tore down any prior server; if this bind fails, @@ -228,7 +248,12 @@ export function createHandoffManager( clear() return false } - pending = { state, createdAt: now(), kind } + pending = { + state, + createdAt: now(), + kind, + ...(connectScope ? { connectScope: { ...connectScope } } : {}), + } const landing = new URL(landingPath, deps.origin()) for (const [key, value] of Object.entries(params)) { landing.searchParams.set(key, value) @@ -259,26 +284,24 @@ export function createHandoffManager( // unknown (offline, signed out): the page then falls back to its normal // login redirect rather than blocking a connect on a failed probe. const userId = await deps.currentUserId() - return beginFlow('connect', '/desktop/connect', { - provider: providerId, - ...(userId ? { user: userId } : {}), - ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), - ...(scope.credentialId ? { credentialId: scope.credentialId } : {}), - }) + return beginFlow( + 'connect', + '/desktop/connect', + { + provider: providerId, + ...(userId ? { user: userId } : {}), + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + ...(scope.credentialId ? { credentialId: scope.credentialId } : {}), + }, + scope + ) }, consume(state: string, kind: HandoffKind) { - if (!pending || pending.kind !== kind) { - return false - } - if (now() - pending.createdAt > HANDOFF_TTL_MS) { - clear() - return false - } - if (!safeCompare(pending.state, state)) { - return false - } - clear() - return true + return consumePending(state, kind) !== null + }, + consumeConnect(state: string) { + const consumed = consumePending(state, 'connect') + return consumed ? { ...(consumed.connectScope ?? {}) } : null }, clear, } @@ -443,6 +466,8 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { export interface ConnectHandoffResult { ok: boolean error?: string + /** Exact Mothership chat attempt, or null for ordinary integration flows. */ + chatAttemptId: string | null } export interface ConnectFlowDeps { @@ -476,19 +501,24 @@ export function createConnectFlow(deps: ConnectFlowDeps): ConnectFlow { return opened }, handleCallback(callback: ConnectHandoffCallback) { - if (!deps.handoff.consume(callback.state, 'connect')) { + const scope = deps.handoff.consumeConnect(callback.state) + if (!scope) { deps.events.record('connect_handoff_state_fail') return } if (callback.error === undefined) { deps.events.record('connect_handoff_ok') deps.focusMainWindow() - deps.notifyRenderer({ ok: true }) + deps.notifyRenderer({ ok: true, chatAttemptId: scope.chatAttemptId ?? null }) return } deps.events.record('connect_handoff_error', { error: callback.error }) deps.focusMainWindow() - deps.notifyRenderer({ ok: false, error: callback.error }) + deps.notifyRenderer({ + ok: false, + error: callback.error, + chatAttemptId: scope.chatAttemptId ?? null, + }) }, } } diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 0d2a554524c..0376da1a065 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -337,14 +337,20 @@ describe('registerIpcHandlers', () => { // Chip-initiated connects carry workspace/credential scope; malformed // scopes (wrong types, unsafe ids) are rejected before the handoff. - expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws1', credentialId: 'cred_1' })).toBe( - true - ) + expect( + await handler?.(appEvent, 'slack', { + workspaceId: 'ws1', + credentialId: 'cred_1', + chatAttemptId: 'attempt_1', + }) + ).toBe(true) expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', { workspaceId: 'ws1', credentialId: 'cred_1', + chatAttemptId: 'attempt_1', }) expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) + expect(await handler?.(appEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false) expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false) }) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 8d0c34ea876..93f95b8d6b3 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -90,6 +90,7 @@ function parseDesktopScope(raw: unknown): string | null { export interface OAuthConnectScope { workspaceId?: string credentialId?: string + chatAttemptId?: string } /** @@ -104,7 +105,11 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi if (typeof raw !== 'object') { return undefined } - const { workspaceId, credentialId } = raw as { workspaceId?: unknown; credentialId?: unknown } + const { workspaceId, credentialId, chatAttemptId } = raw as { + workspaceId?: unknown + credentialId?: unknown + chatAttemptId?: unknown + } if ( workspaceId !== undefined && (typeof workspaceId !== 'string' || !ID_PATTERN.test(workspaceId)) @@ -117,9 +122,16 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi ) { return undefined } + if ( + chatAttemptId !== undefined && + (typeof chatAttemptId !== 'string' || !ID_PATTERN.test(chatAttemptId)) + ) { + return undefined + } return { ...(workspaceId !== undefined ? { workspaceId } : {}), ...(credentialId !== undefined ? { credentialId } : {}), + ...(chatAttemptId !== undefined ? { chatAttemptId } : {}), } } diff --git a/apps/desktop/src/main/telemetry-policy.test.ts b/apps/desktop/src/main/telemetry-policy.test.ts index 29b0cd2f6a1..c4d7ab876b0 100644 --- a/apps/desktop/src/main/telemetry-policy.test.ts +++ b/apps/desktop/src/main/telemetry-policy.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +// telemetry-policy pulls in @/main/navigation, which imports electron. +vi.mock('electron', () => import('@/test/electron-mock')) + import { shouldBlockRequest } from '@/main/telemetry-policy' describe('shouldBlockRequest', () => { diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 5f6ea80f42d..e2c10fb937c 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -373,23 +373,33 @@ aside#nd-sidebar [data-radix-scroll-area-viewport] { Safe because the grid columns are explicit (`0px 300px 1fr 268px 0px`), so removing the placeholder from flow leaves its track intact. `left`/`width` are restated because a fixed box no longer derives them from its grid cell, - and `top`/`height` already come from fumadocs' own utility classes. */ + and `height` already comes from fumadocs' own utility classes. + + Anchoring to `bottom` rather than `top` is what keeps the footer off it: the + offset is how far the footer currently reaches into the viewport (published + by `FooterOverlapProbe`), so the sidebar keeps its full height and slides up + out of view as the footer arrives, the way it did before it was pinned. With + no footer on screen the offset is 0 and this resolves back to top: 92px. */ [data-sidebar-placeholder] { position: fixed !important; left: var(--sidebar-offset); width: var(--fd-sidebar-width); + top: auto !important; + bottom: var(--docs-footer-overlap, 0px) !important; } /* Sidebar divider line — pinned for the same reason, and so it stays glued to the sidebar's right edge. Being fixed takes it out of #nd-docs-layout's grid entirely, so it needs no grid placement and cannot skew a content cell; its - position comes from `left`/`top` alone. */ + position comes from `left`/`top`/`bottom` alone. Unlike the sidebar it is + shortened rather than slid, so it runs from the navbar down to the footer's + top border and the two meet instead of the line stopping short. */ #nd-docs-layout::before { content: ""; display: block; position: fixed; top: 92px; /* below navbar */ - height: calc(100dvh - 92px); + bottom: var(--docs-footer-overlap, 0px); left: calc(var(--sidebar-offset) + var(--fd-sidebar-width)); width: 1px; background-color: var(--surface-active); diff --git a/apps/docs/components/footer/footer-overlap.tsx b/apps/docs/components/footer/footer-overlap.tsx new file mode 100644 index 00000000000..68deafe2d88 --- /dev/null +++ b/apps/docs/components/footer/footer-overlap.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useEffect, useRef } from 'react' + +const OVERLAP_PROPERTY = '--docs-footer-overlap' + +/** + * Publishes how many pixels of the viewport bottom the footer currently covers. + * + * The docs sidebar and its divider are pinned to the viewport, so on their own + * they would run underneath the footer at the end of the page. Both read this as + * their `bottom` and stop at the footer's top edge instead — the sidebar slides + * away with the page and the divider meets the footer's border. + * + * It is deliberately measured against the viewport rather than the document, so + * the value only moves while the footer is actually on screen — a content-height + * change higher up the page cannot disturb the sidebar at all. That was the + * regression #6301 fixed and this must not undo. + */ +export function FooterOverlapProbe() { + const sentinelRef = useRef(null) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + + const root = document.documentElement + let frame = 0 + let published = -1 + + const measure = () => { + frame = 0 + const overlap = Math.max( + 0, + Math.round(window.innerHeight - sentinel.getBoundingClientRect().top) + ) + if (overlap === published) return + published = overlap + root.style.setProperty(OVERLAP_PROPERTY, `${overlap}px`) + } + + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(measure) + } + + measure() + window.addEventListener('scroll', schedule, { passive: true }) + window.addEventListener('resize', schedule) + + const observer = new ResizeObserver(schedule) + observer.observe(document.body) + + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener('scroll', schedule) + window.removeEventListener('resize', schedule) + observer.disconnect() + root.style.removeProperty(OVERLAP_PROPERTY) + } + }, []) + + return ( +
+ ) +} diff --git a/apps/docs/components/footer/footer.tsx b/apps/docs/components/footer/footer.tsx index 75896de0aed..4009cc1de72 100644 --- a/apps/docs/components/footer/footer.tsx +++ b/apps/docs/components/footer/footer.tsx @@ -1,4 +1,5 @@ import Link from 'next/link' +import { FooterOverlapProbe } from '@/components/footer/footer-overlap' import { SimWordmark } from '@/components/ui/sim-logo' import { SIM_SITE_URL } from '@/lib/urls' @@ -133,13 +134,17 @@ function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) /** * Site footer. * - * `relative z-[22]` stacks it above the docs sidebar (z-20) and that sidebar's - * divider (z-21), both of which are pinned to the viewport, so the footer slides - * over them at the end of the page instead of being drawn through. + * The docs sidebar and its divider are pinned to the viewport, so they would run + * underneath a full-bleed footer at the end of the page. `FooterOverlapProbe` + * publishes how far the footer reaches into the viewport and both stop there + * instead. `relative z-[22]` stacks the footer above the sidebar (z-20) and its + * divider (z-21) so that, before the probe's first measurement, the footer covers + * them rather than being drawn through. */ export function Footer() { return (