diff --git a/.agents/skills/add-column-type/SKILL.md b/.agents/skills/add-column-type/SKILL.md index 05f9816aa5f..e9a11707e43 100644 --- a/.agents/skills/add-column-type/SKILL.md +++ b/.agents/skills/add-column-type/SKILL.md @@ -15,7 +15,7 @@ This was not always true: adding `currency` originally took ~40 edits across 32 Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list: ```bash -cd apps/sim && bunx tsc --noEmit -p tsconfig.json +cd apps/sim && bun run type-check ``` You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both. @@ -153,7 +153,7 @@ Registering the *type* is compiler-enforced. Registering its *metadata* is not, ## Final Validation (Required) -1. **`cd apps/sim && bunx tsc --noEmit -p tsconfig.json`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry. +1. **`cd apps/sim && bun run type-check`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry. 2. **Grep for leaks** — `grep -rnE "(===|!==) '{id}'|case '{id}':" apps/sim --include='*.ts' --include='*.tsx' | grep -v column-types/`. (All three forms: a plain `!==` and a `case` are how half of `currency`'s real branches are written.) Hits are expected; judge each. A hit is fine when it mounts a specific React component or encodes a genuinely one-off behavior (`json`'s mono textarea, `date`'s timezone-aware parsing). A hit is a **leak** when it restates something the registry could answer — an icon, a label, a colour, an operator set, a cast, a coercion. Leaks get a registry field, not a new branch. 3. **Run the suite** — `bunx vitest run lib/table 'app/workspace/[workspaceId]/tables' lib/api app/api/table app/api/v1 lib/copilot/tools/server/table`. Existing tests must pass **unchanged**; needing to edit one means you changed behavior for the other types. 4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root. diff --git a/.agents/skills/add-enrichment/SKILL.md b/.agents/skills/add-enrichment/SKILL.md index 44c3e9f95da..8ea6117db57 100644 --- a/.agents/skills/add-enrichment/SKILL.md +++ b/.agents/skills/add-enrichment/SKILL.md @@ -128,7 +128,7 @@ export const ENRICHMENT_REGISTRY: EnrichmentRegistry = { ## Step 5: Verify -1. `bunx tsc --noEmit` (from `apps/sim`, `NODE_OPTIONS=--max-old-space-size=8192`) and `bunx biome check` on the changed files. +1. `bun run type-check` (from `apps/sim`) and `bunx biome check` on the changed files. 2. In a table → **+ New column → Enrichments** → pick the new enrichment, map its inputs to columns, name the output column(s), Save. Confirm it appears in the catalog with its icon/description. 3. With hosted keys (or a workspace BYOK key) configured for each provider's service, run a row and confirm the cell fills; the dev-server log shows `Enrichment hit { provider }`. A row whose providers all miss completes blank; a row where every provider errored shows an error cell. 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/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 946d235643a..54940fd9e58 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -65,22 +65,10 @@ When the user runs `/ship`: echo "❌ block registry audit failed — do not ship" exit 1 } - rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:openapi \ - check:desktop-bridge check:desktop-ipc \ - check:utils check:zustand-v5 \ - check:react-query check:client-boundary check:bare-icons check:icon-paths \ - check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - check:sql-date-binding tool-metadata:check \ - integration-catalog:check skills:check agent-stream-docs:check; do - ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & - done - wait - # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. - # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is - # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. - if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi - echo "✅ all audits passed" + # Runs every audit CI runs, concurrently, and replays the output of any that fail. + # Do not hand-list the audits here: the list is derived in scripts/run-audits.ts, and the + # copy that used to live in this file had already drifted five audits behind package.json. + bun run check:audits || { echo "❌ audit(s) failed — do not ship"; exit 1; } ``` If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. 7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 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-column-type.md b/.claude/commands/add-column-type.md index b390ccc0b98..7b362016218 100644 --- a/.claude/commands/add-column-type.md +++ b/.claude/commands/add-column-type.md @@ -14,7 +14,7 @@ This was not always true: adding `currency` originally took ~40 edits across 32 Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list: ```bash -cd apps/sim && bunx tsc --noEmit -p tsconfig.json +cd apps/sim && bun run type-check ``` You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both. @@ -152,7 +152,7 @@ Registering the *type* is compiler-enforced. Registering its *metadata* is not, ## Final Validation (Required) -1. **`cd apps/sim && bunx tsc --noEmit -p tsconfig.json`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry. +1. **`cd apps/sim && bun run type-check`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry. 2. **Grep for leaks** — `grep -rnE "(===|!==) '{id}'|case '{id}':" apps/sim --include='*.ts' --include='*.tsx' | grep -v column-types/`. (All three forms: a plain `!==` and a `case` are how half of `currency`'s real branches are written.) Hits are expected; judge each. A hit is fine when it mounts a specific React component or encodes a genuinely one-off behavior (`json`'s mono textarea, `date`'s timezone-aware parsing). A hit is a **leak** when it restates something the registry could answer — an icon, a label, a colour, an operator set, a cast, a coercion. Leaks get a registry field, not a new branch. 3. **Run the suite** — `bunx vitest run lib/table 'app/workspace/[workspaceId]/tables' lib/api app/api/table app/api/v1 lib/copilot/tools/server/table`. Existing tests must pass **unchanged**; needing to edit one means you changed behavior for the other types. 4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root. diff --git a/.claude/commands/add-enrichment.md b/.claude/commands/add-enrichment.md index c23d001f70a..b8da9bb3bb4 100644 --- a/.claude/commands/add-enrichment.md +++ b/.claude/commands/add-enrichment.md @@ -127,7 +127,7 @@ export const ENRICHMENT_REGISTRY: EnrichmentRegistry = { ## Step 5: Verify -1. `bunx tsc --noEmit` (from `apps/sim`, `NODE_OPTIONS=--max-old-space-size=8192`) and `bunx biome check` on the changed files. +1. `bun run type-check` (from `apps/sim`) and `bunx biome check` on the changed files. 2. In a table → **+ New column → Enrichments** → pick the new enrichment, map its inputs to columns, name the output column(s), Save. Confirm it appears in the catalog with its icon/description. 3. With hosted keys (or a workspace BYOK key) configured for each provider's service, run a row and confirm the cell fills; the dev-server log shows `Enrichment hit { provider }`. A row whose providers all miss completes blank; a row where every provider errored shows an error cell. 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/ship.md b/.claude/commands/ship.md index c4bb336288b..5380cbd23f0 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -64,22 +64,10 @@ When the user runs `/ship`: echo "❌ block registry audit failed — do not ship" exit 1 } - rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:openapi \ - check:desktop-bridge check:desktop-ipc \ - check:utils check:zustand-v5 \ - check:react-query check:client-boundary check:bare-icons check:icon-paths \ - check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - check:sql-date-binding tool-metadata:check \ - integration-catalog:check skills:check agent-stream-docs:check; do - ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & - done - wait - # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. - # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is - # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. - if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi - echo "✅ all audits passed" + # Runs every audit CI runs, concurrently, and replays the output of any that fail. + # Do not hand-list the audits here: the list is derived in scripts/run-audits.ts, and the + # copy that used to live in this file had already drifted five audits behind package.json. + bun run check:audits || { echo "❌ audit(s) failed — do not ship"; exit 1; } ``` If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. 7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 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/global.md b/.claude/rules/global.md index 4d47c82f60a..86b2ee3be27 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -65,3 +65,10 @@ const filtered = filterUndefined(obj) ## Package Manager Use `bun` and `bunx`, not `npm` and `npx`. + +## Type-checking +`tsc` must resolve to the native (Go) TypeScript 7 compiler. Do not remove the `@typescript/native` alias from the root `devDependencies` — nothing imports it, and deleting it looks harmless. + +`apps/sim` needs `@typescript/typescript6` for its runtime TypeScript API, and that package depends on `@typescript/old` — an alias of `typescript@6` — which declares its own `tsc` bin. Package managers pick bin winners by lexical sort rather than dependency depth, so `@typescript/old` beats `typescript` and `node_modules/.bin/tsc` silently becomes the JavaScript TypeScript 6 compiler: identical diagnostics, ~10x slower (83s vs 8s on `apps/sim`). The `@typescript/native` alias exists only to sort ahead of `@typescript/old`. + +`bun run check:native-typecheck` fails the build if a bare `tsc` stops reporting 7.x — which is also what a newly added package that sorts ahead of `@typescript/native` and ships a `tsc` bin would look like. See [microsoft/typescript-go#4567](https://github.com/microsoft/typescript-go/issues/4567). 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/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index a65acd67004..9312fe72c9b 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -34,7 +34,7 @@ Put state in the URL **only** when it is *all* of: shareable, deep-linkable, boo ## Anti-patterns (forbidden) - Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state. -- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. +- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. **If the target path equals the current path, it is a query mutation, not a navigation** — even when written as a full path template. Re-serializing the path by hand is lossy by construction: it drops every param the template forgets. Use the nuqs setter (`setParams({ key: null }, { history: 'replace', scroll: false })`) — `null` always removes the key, and only the params you name are touched. Both options are already nuqs defaults (see "Conventions"); write them explicitly because a group whose shared options set `history: 'push'` (e.g. `filesUrlKeys`) would otherwise push a back-stack entry for a strip. - `window.history.replaceState`/`pushState` to mutate a param. - Duplicating URL state into a store and syncing it with effects / `popstate` listeners. - High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs). @@ -44,7 +44,7 @@ These reads/mutations are **not** anti-patterns and stay as-is: - **Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination. - **Route navigations** — `router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`. -- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. +- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `new` (invite signup flow), `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. Key names are per-surface: files' `new` is a genuine nuqs param (`files/search-params.ts`), while invite's `new` is a one-shot signup signal. ## Per-feature `search-params.ts` — single source of truth @@ -128,7 +128,22 @@ If a client param must be re-read server-side after a change, set `shallow: fals ## Suspense boundary -`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`. +`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame. + +**Never `fallback={null}` on a page entry.** The route's co-located `loading.tsx` default export *is* the correct fallback — one skeleton serves both the route-level navigation transition (which Next renders automatically) and the in-page suspend (which this boundary renders). If the segment has no `loading.tsx`, add one; the route transition needs it anyway. Import it absolutely (`sim-imports.md`): + +```typescript +import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' +import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading' + +}> + + +``` + +Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`. + +This applies to **page entries**. An inner `` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels". ## Debounced text inputs diff --git a/.claude/skills/add-settings-page/SKILL.md b/.claude/skills/add-settings-page/SKILL.md index 5e84bbbc341..f3440cda8fd 100644 --- a/.claude/skills/add-settings-page/SKILL.md +++ b/.claude/skills/add-settings-page/SKILL.md @@ -37,7 +37,7 @@ Key paths: hand-roll a Save button, a `beforeunload`, or an "Unsaved changes" modal — they're centralized. See the "Save / Discard + unsaved-changes guard" section in `.claude/rules/sim-settings-pages.md`. -5. **Verify:** `cd apps/sim && bunx tsc --noEmit`; `bunx biome check --write `. +5. **Verify:** `cd apps/sim && bun run type-check`; `bunx biome check --write `. ## Mode B — Audit existing settings pages @@ -76,7 +76,7 @@ For each page component, confirm the checklist in `.claude/rules/sim-settings-pa unless they're also being changed for an unrelated, deliberate reason. 7. Remove now-unused imports (`ChipInput`/`Search`) ONLY after grepping that they are not still used elsewhere in the file (e.g. by a detail view). -8. **Verify the whole sweep:** `tsc --noEmit`, `biome check` on every touched +8. **Verify the whole sweep:** `bun run type-check`, `biome check` on every touched file, and run the affected pages' tests. Diff each file against the base and confirm the change is purely structural before shipping. @@ -105,5 +105,5 @@ contract. Then, per page: 7. Check what the old row rendered *beside* the title (a badge, a timestamp, a transport label). The row's title truncates as one unit, so anything folded into it can be ellipsised away — move it to `description` or `badge`. -8. Verify: `tsc --noEmit`, `biome check`, the page's tests, and a diff read of +8. Verify: `bun run type-check`, `biome check`, the page's tests, and a diff read of every converted block for lost props, conditions, and `key` placement. diff --git a/.cursor/commands/add-column-type.md b/.cursor/commands/add-column-type.md index f0be823ab6e..00f00215348 100644 --- a/.cursor/commands/add-column-type.md +++ b/.cursor/commands/add-column-type.md @@ -9,7 +9,7 @@ This was not always true: adding `currency` originally took ~40 edits across 32 Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list: ```bash -cd apps/sim && bunx tsc --noEmit -p tsconfig.json +cd apps/sim && bun run type-check ``` You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both. @@ -147,7 +147,7 @@ Registering the *type* is compiler-enforced. Registering its *metadata* is not, ## Final Validation (Required) -1. **`cd apps/sim && bunx tsc --noEmit -p tsconfig.json`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry. +1. **`cd apps/sim && bun run type-check`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry. 2. **Grep for leaks** — `grep -rnE "(===|!==) '{id}'|case '{id}':" apps/sim --include='*.ts' --include='*.tsx' | grep -v column-types/`. (All three forms: a plain `!==` and a `case` are how half of `currency`'s real branches are written.) Hits are expected; judge each. A hit is fine when it mounts a specific React component or encodes a genuinely one-off behavior (`json`'s mono textarea, `date`'s timezone-aware parsing). A hit is a **leak** when it restates something the registry could answer — an icon, a label, a colour, an operator set, a cast, a coercion. Leaks get a registry field, not a new branch. 3. **Run the suite** — `bunx vitest run lib/table 'app/workspace/[workspaceId]/tables' lib/api app/api/table app/api/v1 lib/copilot/tools/server/table`. Existing tests must pass **unchanged**; needing to edit one means you changed behavior for the other types. 4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root. diff --git a/.cursor/commands/add-enrichment.md b/.cursor/commands/add-enrichment.md index 0644cce420f..93bfe6d9195 100644 --- a/.cursor/commands/add-enrichment.md +++ b/.cursor/commands/add-enrichment.md @@ -122,7 +122,7 @@ export const ENRICHMENT_REGISTRY: EnrichmentRegistry = { ## Step 5: Verify -1. `bunx tsc --noEmit` (from `apps/sim`, `NODE_OPTIONS=--max-old-space-size=8192`) and `bunx biome check` on the changed files. +1. `bun run type-check` (from `apps/sim`) and `bunx biome check` on the changed files. 2. In a table → **+ New column → Enrichments** → pick the new enrichment, map its inputs to columns, name the output column(s), Save. Confirm it appears in the catalog with its icon/description. 3. With hosted keys (or a workspace BYOK key) configured for each provider's service, run a row and confirm the cell fills; the dev-server log shows `Enrichment hit { provider }`. A row whose providers all miss completes blank; a row where every provider errored shows an error cell. 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/ship.md b/.cursor/commands/ship.md index c5d92d97ae4..7421187736d 100644 --- a/.cursor/commands/ship.md +++ b/.cursor/commands/ship.md @@ -59,22 +59,10 @@ When the user runs `/ship`: echo "❌ block registry audit failed — do not ship" exit 1 } - rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:openapi \ - check:desktop-bridge check:desktop-ipc \ - check:utils check:zustand-v5 \ - check:react-query check:client-boundary check:bare-icons check:icon-paths \ - check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - check:sql-date-binding tool-metadata:check \ - integration-catalog:check skills:check agent-stream-docs:check; do - ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & - done - wait - # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. - # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is - # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. - if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi - echo "✅ all audits passed" + # Runs every audit CI runs, concurrently, and replays the output of any that fail. + # Do not hand-list the audits here: the list is derived in scripts/run-audits.ts, and the + # copy that used to live in this file had already drifted five audits behind package.json. + bun run check:audits || { echo "❌ audit(s) failed — do not ship"; exit 1; } ``` If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. 7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 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/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 16a2dbb25bf..09cdf7dbb48 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -117,65 +117,11 @@ jobs: - name: Lint code run: bun run lint:check - - name: Enforce monorepo boundaries - run: bun run check:boundaries - - - name: API contract boundary audit - run: bun run check:api-validation:strict - - - name: OpenAPI spec validation - run: bun run check:openapi - - - name: Desktop bridge contract audit - run: bun run check:desktop-bridge - - # Complements the bridge audit above, which compares against a snapshot - # this same PR is allowed to regenerate. This one derives every fact from - # the source both sides execute, so it has no such blind spot. - - name: Desktop IPC contract audit - run: bun run check:desktop-ipc - - - name: Shared utils enforcement audit - run: bun run check:utils - - - name: Zustand v5 selector audit - run: bun run check:zustand-v5 - - - name: React Query pattern audit - run: bun run check:react-query - - - name: Client boundary import audit - run: bun run check:client-boundary - - - name: Bare-icon theme-safety audit - run: bun run check:bare-icons - - - name: Icon SVG path validity audit - run: bun run check:icon-paths - - - name: Verify realtime prune graph - run: bun run check:realtime-prune - - - name: Tool registry client-boundary audit - run: bun run check:tool-registry-boundary - - - name: Tool request transport boundary audit - run: bun run check:tool-request-boundary - - - name: SQL Date binding audit - run: bun run check:sql-date-binding - - - name: Verify generated tool metadata is in sync - run: bun run tool-metadata:check - - - name: Verify integration deployment metadata is in sync - run: bun run integration-catalog:check - - - name: Verify skill projections are in sync - run: bun run skills:check - - - name: Verify agent stream capability docs are in sync - run: bun run agent-stream-docs:check + # Every zero-argument `check:*` script, run concurrently. The list is derived in + # scripts/run-audits.ts, which also writes the per-audit timing table to the job + # summary and annotates failures. Audits needing a base ref stay separate below. + - name: Repo audits + run: bun run check:audits - name: Migration safety (zero-downtime) audit run: | @@ -225,21 +171,6 @@ jobs: fi echo "✅ Schema and migrations are in sync" - # DEAD PATH: nothing generates `apps/sim/coverage`. The test step runs - # `vitest run` without `--coverage`, and vitest.config.ts declares no - # coverage provider, so this uploads nothing and still reports success in - # ~1s (`fail_ci_if_error: false` hides it). `@vitest/coverage-v8` IS - # installed, so wiring it up is possible — but coverage instrumentation - # costs test time and nothing gates on the result today. Left in place - # pending a decision to either enable coverage or drop this step; do not - # read its green tick as "coverage was published". - - name: Upload coverage to Codecov - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 - with: - directory: ./apps/sim/coverage - fail_ci_if_error: false - verbose: true - # Next.js production build, in parallel with lint + tests. Sticky disks are # cloned from the last committed snapshot per job and committed last-writer- # wins, so concurrent mounts are safe. The bun/node_modules disks are shared diff --git a/CLAUDE.md b/CLAUDE.md index d2e376dbd9f..fc63380d153 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ You are a professional software engineer. All code must follow best practices: a - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline - **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx` +- **Type-checking**: Run `bun run type-check` (per workspace) or `bunx turbo run type-check` (all of them). Do not remove the `@typescript/native` alias from the root `devDependencies` — nothing imports it, but it is what makes a bare `tsc` resolve to the native TypeScript 7 compiler instead of the ~10x slower JavaScript TypeScript 6 one that `@typescript/typescript6` pulls in transitively. `bun run check:native-typecheck` enforces this ## Architecture @@ -377,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/channel-identity.test.ts b/apps/desktop/src/main/channel-identity.test.ts index 7a7d06613e1..7cc01ac8412 100644 --- a/apps/desktop/src/main/channel-identity.test.ts +++ b/apps/desktop/src/main/channel-identity.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + import { APP_NAME_FOR_CHANNEL, channelForOrigin, DEFAULT_ORIGIN } from '@/main/config' import { classifyNavigation } from '@/main/navigation' import { DEV, identityForOrigin, LOCAL, PROD, STAGING } from '../../scripts/channels' 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 (