diff --git a/.github/CI.md b/.github/CI.md index f5d005489f6..15b06c5be45 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -2,9 +2,9 @@ The `changes` job runs Turborepo 2.9.14's `affectedTasks` query before installing application dependencies or provisioning services. `turbo.json` records package dependency propagation; `apps/sim/turbo.json` partitions the app into shared runtime, server, workspace UI, and acceptance tasks. `.github/ci-suites.json` turns those tasks into job matrices. The uploaded `ci-plan` artifact names changed files, selected tasks, attribution reasons, and any fallback. -A PR compares its merge base with the checked-out merge commit. Main/staging pushes execute all checks; the parent `ci` workflow skips checks on `dev` pushes, while PRs targeting `dev` still run checks. A manual `checks` run executes all checks unless supplied a full base commit SHA to reproduce a scoped plan. Missing history, an empty diff, unknown files outside the workspace graph, root script/CI changes, or a planner error run every check. Missing selection outputs fail the aggregate gate. The gate also rejects an omitted dependency, any failed/cancelled job, and any skip that its explicit selection flag did not authorize. +A PR compares the checked-out GitHub merge commit with its first parent: the actual target revision tested by that merge. The PR event's base SHA can become stale when the target advances. Scoping requires a matching GitHub merge ref and SHA, exactly two merge parents, and a usable target parent; otherwise every check runs. This works with the existing depth-two checkout without fetching older ancestry. Main/staging pushes execute all checks; the parent `ci` workflow skips checks on `dev` pushes, while PRs targeting `dev` still run checks. A manual `checks` run executes all checks unless supplied a full base commit SHA to reproduce a scoped plan. Missing history, an empty diff, unknown files outside the workspace graph, root script/CI changes, or a planner error run every check. Missing selection outputs fail the aggregate gate. The gate also rejects an omitted dependency, any failed/cancelled job, and any skip that its explicit selection flag did not authorize. -Dedicated docs verification uses the same planner, comparing pushes with their previous commit and PRs with their merge base. Site acceptance follows `docs#build` and its transitive package dependencies. The app's docs-consumer task depends on server runtime and that docs build: backend-only changes run the platform consumer against the docs app, while site changes also run corpus and browser checks. Independent SDK and unit-only edits skip those production jobs. The required `docs HTTP` gate verifies explicit site/platform selections before accepting any skip. +Dedicated docs verification uses the same planner, comparing pushes with their previous commit and PRs with the tested merge's first parent. Site acceptance follows `docs#build` and its transitive package dependencies. The app's docs-consumer task depends on server runtime and that docs build: backend-only changes run the platform consumer against the docs app, while site changes also run corpus and browser checks. Independent SDK and unit-only edits skip those production jobs. The required `docs HTTP` gate verifies explicit site/platform selections before accepting any skip. Docs production builds run once and publish an artifact to isolated HTTP, corpus, Chromium, and WebKit jobs. Source checks keep their own checkout because generator verification mutates inputs. Content caches use separate lint/source/build namespaces; generation validates every restore, repairs missing declarations, and removes obsolete modules. The Next build cache keeps its supported incremental behavior. Content and Next caches have size limits, and Next cache files expire after seven days; successful content eviction happens after all consumers finish. @@ -36,6 +36,8 @@ Closing a same-repository pull request deletes only its GitHub merge-ref caches Memory-heavy builds and browser jobs retain appropriately sized Blacksmith runners. Electron uses 4-vCPU Blacksmith: both shards passed within 14 seconds of the 8-vCPU control; the free GitHub rest shard was slower and its lease run stalled. +WebKit acceptance on Blacksmith uses the matching, digest-pinned Playwright browser image for mobile and docs navigation. The app, database fixtures, native modules, and harness stay on the host and retain loopback URLs. Chromium and GitHub fallback keep native browser installation and launch. Update both workspace Playwright dependencies, the image index digest, and the expected client version/revision checks together; verify the installed release's native launch timeout and real WebKit acceptance before changing the pin. + Every HTTP and browser group consumes the same production build in an isolated job/database. Electron also uses that build and skips its development-route warm-up. Local HTTP and Electron development runs retain their dev-server behavior. Build inputs exclude test-only files so a harness edit can reuse an unchanged production build. Desktop inbox protocol, assistant actions UI, and organization search UI run as three parallel HTTP jobs with isolated databases, apps, and reports. Each harness owns its acceptance task, so an edit selects its own job. The inbox protocol task depends on server and realtime code; the browser tasks also depend on workspace UI. Desktop inbox needs no browser. The UI jobs and browser extension install Chromium while their app starts, waiting for both before executing assertions; browser-install failures still fail the job and preserve the install log. No acceptance assertions are removed by the split. @@ -44,7 +46,7 @@ Live Electron acceptance tracks its live-app suite, fixtures, and Playwright con Desktop smoke weights also stay outside the desktop runtime task. Editing weights reruns desktop partition coverage without selecting live-app acceptance. -Browser extension acceptance runs native interaction and access-revalidation groups in parallel jobs with isolated databases, apps, browser profiles, and reports. Both retain the same task boundary, so an affected extension harness selects both groups. Native streaming and first-open capture checks retain real timers. Access checks install Playwright's clock before the app mounts: advancing production polling timers preserves real HTTP and database authorization checks without waiting through each polling interval. Capture timestamps originate in Chromium's isolated world, so the harness restores wall time after each advance. The local `browser-extension` group still runs the complete suite; live-provider runs require this complete mode and retain native timing throughout. +Browser extension acceptance runs navigation, composer, and access-revalidation groups in three parallel jobs with isolated databases, apps, browser profiles, and reports. All three retain the same task boundary, so an affected extension harness selects every group. Navigation and composer checks retain real timers. Access checks install Playwright's clock before the app mounts: advancing production polling timers preserves real HTTP and database authorization checks without waiting through each polling interval. Capture timestamps originate in Chromium's isolated world, so the harness restores wall time after each advance. The local `browser-extension` group still runs the complete suite; live-provider runs require this complete mode and retain native timing throughout. Integration shard weights come from successful reports for both push and migration provisioning paths. Refresh them with `scripts/update-integration-durations.ts`, then check the new partition against a separate complete run before adopting it. Weights only change scheduling; discovery still owns coverage. The current refresh uses runs [41348786822](https://github.com/simstudioai/sim/actions/runs/41348786822) and [41377145857](https://github.com/simstudioai/sim/actions/runs/41377145857), with [41377810718](https://github.com/simstudioai/sim/actions/runs/41377810718) held out for validation. diff --git a/.github/ci-suites.json b/.github/ci-suites.json index f695a93a56a..48b3bf98c08 100644 --- a/.github/ci-suites.json +++ b/.github/ci-suites.json @@ -25,7 +25,11 @@ "task": "ci:e2e-organization-search" }, { - "group": "browser-extension-native", + "group": "browser-extension-navigation", + "task": "ci:e2e-browser-extension" + }, + { + "group": "browser-extension-composer", "task": "ci:e2e-browser-extension" }, { diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index cb86412ab48..e05cfbf3d09 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -3,7 +3,7 @@ # CI groups, which reuse the production build from `e2e-build`. # The job extracts it into apps/sim/.next before running these suites. # -# Usage: http-e2e.sh (run from apps/sim) +# Usage: http-e2e.sh (run from apps/sim) # # The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of # its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e. @@ -20,7 +20,7 @@ # telemetry flush runs detached and still writes .next/dev). set -euo pipefail -group=${1:?usage: http-e2e.sh } +group=${1:?usage: http-e2e.sh } report_dir="$RUNNER_TEMP/e2e" ready_timeout_seconds=600 mkdir -p "$report_dir" @@ -164,7 +164,11 @@ case "$group" in export NEXT_PUBLIC_CHAT_DISABLED=true export SIM_LINKS=true export PEOPLE=true + export CRON_SECRET=people-directory-sync-local-e2e-secret + start_browser_install people chromium start_app scim 3017 SCIM record-http-status + wait_for_browsers + export PEOPLE_E2E_SCREENSHOT_DIR="$report_dir/people-ui-report.json.artifacts" SCIM_E2E_BASE_URL="$local_app_url" \ SCIM_E2E_DATABASE_URL="$DATABASE_URL" \ SCIM_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ @@ -233,7 +237,7 @@ case "$group" in export COPILOT_TOOL_PERMISSIONS_ENABLED=true export KNOWLEDGE_MEMBER_ACCESS=true CREDENTIAL_GROUPS=true ACCESS_CONTROL_ENABLED=true export NEXT_PUBLIC_ACCESS_CONTROL_ENABLED=true NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true - if [ "$group" != desktop-inbox ]; then start_browser_install "$group" chromium; fi + if [ "$group" != desktop-inbox ]; then start_browser_install "$group" --only-shell chromium; fi export INTERNAL_API_SECRET=desktop-inbox-http-ci-local-secret-at-least-32-characters export DB_TX_TRIPWIRE=throw start_app "$group" 3019 "$group" record-http-status @@ -264,10 +268,12 @@ case "$group" in esac ;; - browser-extension|browser-extension-native|browser-extension-access) + browser-extension|browser-extension-native|browser-extension-navigation|browser-extension-composer|browser-extension-access) extension_group=all case "$group" in browser-extension-native) extension_group=native ;; + browser-extension-navigation) extension_group=navigation ;; + browser-extension-composer) extension_group=composer ;; browser-extension-access) extension_group=access ;; esac export REDIS_URL=redis://127.0.0.1:6379 @@ -275,7 +281,7 @@ case "$group" in export KNOWLEDGE_MEMBER_ACCESS=true CREDENTIAL_GROUPS=true ACCESS_CONTROL_ENABLED=true export NEXT_PUBLIC_ACCESS_CONTROL_ENABLED=true export API_ENCRYPTION_KEY="$ENCRYPTION_KEY" - start_browser_install "$group" chromium + start_browser_install "$group" --no-shell chromium extension_identity=$(bun - <<'JS' import { generateKeyPairSync, createHash } from 'node:crypto' const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) @@ -312,10 +318,19 @@ JS export INTERNAL_API_SECRET=mobile-http-ci-local-secret-at-least-32-characters export NEXT_PUBLIC_SOCKET_URL=http://127.0.0.1:3025 SOCKET_SERVER_URL=http://127.0.0.1:3025 read -ra browsers <<< "${MOBILE_E2E_BROWSER:-chromium webkit}" - start_browser_install mobile "${browsers[@]}" + if [ "${PLAYWRIGHT_WEBKIT_WS_ENDPOINT+x}" = x ]; then + if [ "${MOBILE_E2E_BROWSER:-}" != webkit ]; then + echo "::error::A remote endpoint requires explicit WebKit selection." >&2 + exit 1 + fi + else + start_browser_install mobile --only-shell "${browsers[@]}" + fi start_app mobile 3024 'mobile browser' record-http-status start_realtime mobile 3025 - wait_for_browsers + if [ "${PLAYWRIGHT_WEBKIT_WS_ENDPOINT+x}" != x ]; then + wait_for_browsers + fi MOBILE_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \ MOBILE_E2E_DATABASE_URL="$DATABASE_URL" \ MOBILE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ diff --git a/.github/scripts/playwright-server.sh b/.github/scripts/playwright-server.sh new file mode 100755 index 00000000000..284ce77c63b --- /dev/null +++ b/.github/scripts/playwright-server.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Owns one pinned WebKit server; start prints its loopback endpoint, stop preserves its logs. +set -euo pipefail +umask 077 + +action=${1:-} +state_argument=${2:-} +if [ "$#" -ne 2 ] || { [ "$action" != start ] && [ "$action" != stop ]; }; then + echo 'Usage: playwright-server.sh ' >&2 + exit 2 +fi +: "${RUNNER_TEMP:?RUNNER_TEMP must be set}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must be set}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID must be set}" +: "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT must be set}" +: "${GITHUB_JOB:?GITHUB_JOB must be set}" +if ! [[ "$GITHUB_RUN_ID" =~ ^[1-9][0-9]*$ && "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ && "$GITHUB_JOB" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo 'Invalid GitHub job ownership metadata.' >&2 + exit 2 +fi +state=$(node - "$RUNNER_TEMP" "$state_argument" <<'JS' +const fs = require('node:fs') +const path = require('node:path') +const root = fs.realpathSync(process.argv[2]) +const state = path.resolve(process.argv[3]) +if (path.dirname(state) !== root || (fs.existsSync(state) && fs.realpathSync(state) !== state)) + throw new Error('State must be a direct, non-symlink child of RUNNER_TEMP') +process.stdout.write(state) +JS +) +state_key=$(node -e 'process.stdout.write(require("node:crypto").createHash("sha256").update(process.argv[1]).digest("hex"))' "$state") +image='mcr.microsoft.com/playwright@sha256:2f05f8a734509a7b3f26a912639488ee08f58a7b292d14d62271bcff9038768f' +starting=false + +stop_container() { + local cid inspection remaining status=0 + [ -f "$state/cid" ] || return 0 + cid=$(< "$state/cid") + if ! [[ "$cid" =~ ^[0-9a-f]{64}$ ]]; then + echo 'Refusing to stop an invalid container ID.' >&2 + return 1 + fi + if ! inspection=$(docker container inspect "$cid" 2> "$state/inspect-error.log"); then + remaining=$(docker container ls --all --no-trunc --filter "id=$cid" --format '{{.ID}}') || return 1 + [ -z "$remaining" ] && return 0 + cat "$state/inspect-error.log" >&2 + return 1 + fi + if ! jq -e --arg repository "$GITHUB_REPOSITORY" --arg run "$GITHUB_RUN_ID" \ + --arg attempt "$GITHUB_RUN_ATTEMPT" --arg job "$GITHUB_JOB" --arg state "$state_key" \ + '.[0].Config.Labels | .["com.sim.ci.playwright-server"] == "true" + and .["com.sim.ci.repository"] == $repository and .["com.sim.ci.run"] == $run + and .["com.sim.ci.attempt"] == $attempt and .["com.sim.ci.job"] == $job + and .["com.sim.ci.state"] == $state' <<< "$inspection" > /dev/null; then + echo 'Refusing to stop a container owned by another job or state directory.' >&2 + return 1 + fi + docker stop --timeout 10 "$cid" > "$state/stop.log" 2>&1 || status=1 + docker logs "$cid" > "$state/server.log" 2>&1 || status=1 + docker rm "$cid" > "$state/remove.log" 2>&1 || status=1 + return "$status" +} + +trap ' + status=$? + trap - EXIT + if [ "$status" -ne 0 ] && [ "$starting" = true ]; then + stop_container || echo "Owned Playwright server cleanup failed; preserve its state and logs." >&2 + fi + exit "$status" +' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if [ "$action" = stop ]; then + stop_container + exit 0 +fi +if [ -e "$state/cid" ]; then + echo 'Refusing to reuse an existing Playwright server state.' >&2 + exit 1 +fi +mkdir -p -- "$state" +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +driver=$(node - "$repo_root/apps/sim" <<'JS' +const fs = require('node:fs') +const path = require('node:path') +const packagePath = require.resolve('playwright-core/package.json', { paths: [process.argv[2]] }) +const root = fs.realpathSync(path.dirname(packagePath)) +const manifest = JSON.parse(fs.readFileSync(path.join(root, 'browsers.json'), 'utf8')) +if (JSON.parse(fs.readFileSync(packagePath, 'utf8')).version !== '1.64.1' || + manifest.browsers.find(browser => browser.name === 'webkit')?.revision !== '2370') + throw new Error('The pinned WebKit image requires Playwright 1.64.1, revision 2370') +process.stdout.write(root) +JS +) +docker pull "$image" > "$state/pull.log" 2>&1 +starting=true +docker run --detach --init --cidfile "$state/cid" \ + --name "sim-ci-playwright-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$GITHUB_JOB-$$" \ + --label com.sim.ci.playwright-server=true --label "com.sim.ci.repository=$GITHUB_REPOSITORY" \ + --label "com.sim.ci.run=$GITHUB_RUN_ID" --label "com.sim.ci.attempt=$GITHUB_RUN_ATTEMPT" \ + --label "com.sim.ci.job=$GITHUB_JOB" --label "com.sim.ci.state=$state_key" \ + --network host --cpus 8 --memory 4g --memory-swap 4g --pids-limit 512 --shm-size 256m \ + --mount "type=bind,src=$driver,dst=/opt/playwright-core,readonly" \ + "$image" node /opt/playwright-core/cli.js run-server --host 127.0.0.1 --port 0 \ + > "$state/start.log" 2>&1 +cid=$(< "$state/cid") +if ! [[ "$cid" =~ ^[0-9a-f]{64}$ ]]; then + echo 'Docker did not return a complete container ID.' >&2 + exit 1 +fi +docker container inspect "$cid" > "$state/metadata.json" +deadline=$((SECONDS + 30)) +while [ "$SECONDS" -lt "$deadline" ]; do + live=$(docker container inspect --format '{{.State.Running}} {{.State.Pid}}' "$cid") + if ! [[ "$live" =~ ^true\ [1-9][0-9]*$ ]]; then + docker logs "$cid" > "$state/server.log" 2>&1 + echo 'Playwright server exited before readiness.' >&2 + exit 1 + fi + docker logs "$cid" > "$state/server.log" 2>&1 + endpoint=$(sed -n 's@^Listening on \(ws://127\.0\.0\.1:[1-9][0-9]*/\)$@\1@p' "$state/server.log") + if [ -n "$endpoint" ]; then + node - "$endpoint" <<'JS' +const endpoint = process.argv[2] +if (!/^ws:\/\/127\.0\.0\.1:[1-9]\d*\/$/.test(endpoint)) throw new Error('Invalid server endpoint') +new URL(endpoint) +process.stdout.write(`${endpoint}\n`) +JS + exit 0 + fi + sleep 0.1 +done +echo 'Playwright server did not report a live loopback endpoint within 30 seconds.' >&2 +exit 1 diff --git a/.github/scripts/wait-for-push-checks.sh b/.github/scripts/wait-for-push-checks.sh new file mode 100755 index 00000000000..1f69bb115e5 --- /dev/null +++ b/.github/scripts/wait-for-push-checks.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GITHUB_REPOSITORY:?}" +: "${GITHUB_REPOSITORY_ID:?}" +: "${GITHUB_REF_NAME:?}" +: "${GITHUB_SHA:?}" + +provenance=$(cat <<'JQ' + def trusted: + .path == ".github/workflows/validation.yml" + and .event == "push" + and .head_sha == $sha + and .head_branch == $branch + and .repository.full_name == $repo + and .repository.id == $repo_id + and .head_repository.full_name == $repo + and .head_repository.id == $repo_id + and (.id | type == "number" and . > 0 and floor == .) + and (.workflow_id | type == "number" and . > 0 and floor == .); +JQ +) + +query() { + jq --arg repo "$GITHUB_REPOSITORY" --arg branch "$GITHUB_REF_NAME" \ + --arg sha "$GITHUB_SHA" --argjson repo_id "$GITHUB_REPOSITORY_ID" "$@" +} + +run_id='' +deadline=$((SECONDS + 120)) +while [ "$SECONDS" -lt "$deadline" ]; do + response=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs" --method GET \ + -f event=push -f branch="$GITHUB_REF_NAME" -f head_sha="$GITHUB_SHA" -F per_page=100) + selection=$(query "$provenance + if (.workflow_runs | type) != \"array\" or .total_count != (.workflow_runs | length) + then error(\"Incomplete validation run listing\") else .workflow_runs end + | map(select(trusted)) + | if length > 1 then error(\"Ambiguous matching push validations\") + elif length == 1 then .[0] | [.id, .workflow_id] | @tsv + else empty end" -r <<< "$response") + if [ -n "$selection" ]; then + IFS=$'\t' read -r run_id workflow_id <<< "$selection" + break + fi + sleep 2 +done + +if [ -z "$run_id" ]; then + echo '::error::No trusted push validation appeared for this exact commit.' >&2 + exit 1 +fi + +gh run watch "$run_id" --repo "$GITHUB_REPOSITORY" --exit-status --interval 30 --compact +response=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id") +query -e --argjson run_id "$run_id" --argjson workflow_id "$workflow_id" "$provenance + trusted and .id == \$run_id and .workflow_id == \$workflow_id + and .status == \"completed\" and .conclusion == \"success\"" <<< "$response" > /dev/null +echo "Push validation $run_id passed for $GITHUB_SHA." diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ad8bb32f0f7..e0d1d77f3b8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -129,7 +129,15 @@ jobs: ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' steps: - - name: Checkout code + # The writable mirror is shared per repository, so only trusted PR jobs use it. + # Forks and deployment-gating pushes use a plain checkout, matching cache isolation. + - &checkout-mirror + name: Checkout code (mirror) + if: (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork + uses: useblacksmith/checkout@2b9fe87d796feea153b455ec2f3125b471a7b0e7 # v1.8.2 + - &checkout-plain + name: Checkout code + if: (vars.CI_PROVIDER != '' && vars.CI_PROVIDER != 'blacksmith') || github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - name: Setup workspace @@ -348,19 +356,8 @@ jobs: ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' steps: - # A same-repository pull request checks out through a git mirror on a sticky disk, so a slow - # clone from GitHub (1 in 10 took a minute or more, up to 4) no longer sets the run's pace. - # The mirror is one disk per repository that job steps can write to, so fork pull requests - # and pushes, whose checks gate a deploy, keep the plain checkout: same trust split as the - # setup action's caches. - - &checkout-mirror - name: Checkout code (mirror) - if: (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork - uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1 - - &checkout-plain - name: Checkout code - if: (vars.CI_PROVIDER != '' && vars.CI_PROVIDER != 'blacksmith') || github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + - *checkout-mirror + - *checkout-plain - name: Setup workspace @@ -376,11 +373,43 @@ jobs: - *extract-e2e-build - name: Run end-to-end suites + id: acceptance working-directory: apps/sim env: MOBILE_E2E_BROWSER: ${{ matrix.browser }} MOBILE_E2E_SHARD: ${{ matrix.shard }} - run: bash ../../.github/scripts/http-e2e.sh "${{ matrix.group }}" + run: | + unset PLAYWRIGHT_WEBKIT_WS_ENDPOINT + if [ "${{ matrix.browser == 'webkit' && (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') }}" = true ]; then + webkit_state="$RUNNER_TEMP/playwright-webkit-server" + trap ' + status=$? + trap - EXIT INT TERM + if ! bash ../../.github/scripts/playwright-server.sh stop "$webkit_state"; then + echo "::error::Owned WebKit server cleanup failed." >&2 + if [ "$status" -eq 0 ]; then status=1; fi + fi + exit "$status" + ' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + PLAYWRIGHT_WEBKIT_WS_ENDPOINT=$(bash ../../.github/scripts/playwright-server.sh start "$webkit_state") + export PLAYWRIGHT_WEBKIT_WS_ENDPOINT + fi + bash ../../.github/scripts/http-e2e.sh "${{ matrix.group }}" + + - name: Stop the owned WebKit server + if: always() && matrix.browser == 'webkit' && (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && steps.acceptance.outcome != 'skipped' + run: bash .github/scripts/playwright-server.sh stop "$RUNNER_TEMP/playwright-webkit-server" + + - name: Upload WebKit server failure logs + if: failure() && matrix.browser == 'webkit' && (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: webkit-server-${{ matrix.artifact }} + path: ${{ runner.temp }}/playwright-webkit-server/ + if-no-files-found: ignore + retention-days: 7 - name: Upload end-to-end reports and server logs if: failure() @@ -578,7 +607,7 @@ jobs: # Same provider/trust split as the build jobs' checkout. - name: Checkout code (mirror) if: (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork - uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1 + uses: useblacksmith/checkout@2b9fe87d796feea153b455ec2f3125b471a7b0e7 # v1.8.2 with: fetch-depth: 2 - name: Checkout code @@ -787,7 +816,7 @@ jobs: steps: - name: Checkout code (mirror) if: needs.changes.outputs.test_app == 'true' && (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork - uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1 + uses: useblacksmith/checkout@2b9fe87d796feea153b455ec2f3125b471a7b0e7 # v1.8.2 with: fetch-depth: 2 - name: Checkout code diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 604fcf09072..dff08f6ecfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,30 @@ permissions: jobs: checks: name: checks - if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request' + if: github.event_name == 'pull_request' uses: ./.github/workflows/checks.yml + push-checks: + name: push-checks + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + runs-on: ubuntu-24.04 + # Validation can chain a 25-minute build and a 30-minute acceptance job. + timeout-minutes: 90 + permissions: + contents: read + actions: read + checks: read + steps: + - name: Checkout admission gate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + sparse-checkout: .github/scripts + persist-credentials: false + - name: Require this commit's independent push validation + env: + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/wait-for-push-checks.sh + # Detect if this is a version release commit (e.g., "v0.5.24: ...") # Smallest runner on purpose: a few seconds of pure shell over the commit # message, no checkout and no install. @@ -116,14 +137,14 @@ jobs: contents: read id-token: write name: migrate - needs: [checks] + needs: [push-checks] # Explicit need results instead of the implicit success(): a skipped job # anywhere in the transitive needs chain silently fails implicit success() # and cascade-skips the deploy chain (migrate -> promote -> # CodeDeploy) — this bit us on 2026-07-23. State requirements explicitly. if: >- !cancelled() && - needs.checks.result == 'success' && + needs.push-checks.result == 'success' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') uses: ./.github/workflows/migrate.yml @@ -249,6 +270,9 @@ jobs: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') runs-on: &runner-4vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 + permissions: + contents: read + id-token: write outputs: version: ${{ steps.deploy.outputs.deploymentVersion }} environment: ${{ steps.target.outputs.environment }} @@ -274,6 +298,15 @@ jobs: echo "environment=$TRIGGER_ENV" >> "$GITHUB_OUTPUT" echo "preview_branch=$TRIGGER_BRANCH" >> "$GITHUB_OUTPUT" + # The deploy syncs an environment listed in `vars.TRIGGER_ENV_SYNC_TARGETS` from the app's + # env-vars secret (see `@sim/runtime-secrets/trigger-env`), which needs read access to that secret. + - name: Configure AWS credentials for the env sync + if: contains(format(',{0},', vars.TRIGGER_ENV_SYNC_TARGETS), format(',{0},', steps.target.outputs.environment)) + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6 + with: + role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} + - name: Upload Trigger.dev version without promotion id: deploy working-directory: ./apps/sim @@ -282,6 +315,7 @@ jobs: TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} TRIGGER_ENV: ${{ steps.target.outputs.environment }} TRIGGER_BRANCH: ${{ steps.target.outputs.preview_branch }} + TRIGGER_ENV_SYNC_TARGETS: ${{ vars.TRIGGER_ENV_SYNC_TARGETS }} run: | set -eo pipefail if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then diff --git a/.github/workflows/docs-negotiation.yml b/.github/workflows/docs-negotiation.yml index 14a52a52206..0fcdf596276 100644 --- a/.github/workflows/docs-negotiation.yml +++ b/.github/workflows/docs-negotiation.yml @@ -19,6 +19,7 @@ on: - '.github/actions/cache/**' - '.github/actions/setup/**' - '.github/scripts/stop-session.sh' + - '.github/scripts/playwright-server.sh' push: branches: [staging] paths: @@ -38,6 +39,7 @@ on: - '.github/actions/cache/**' - '.github/actions/setup/**' - '.github/scripts/stop-session.sh' + - '.github/scripts/playwright-server.sh' workflow_dispatch: inputs: benchmark: @@ -342,9 +344,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - with: - fetch-depth: 0 - filter: blob:none - name: Setup workspace uses: ./.github/actions/setup with: @@ -357,30 +356,33 @@ jobs: run: tar -I zstd -xf docs-build.tar.zst -C apps/docs - name: Set up Java for copied request examples if: matrix.group == 'http' - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: '17' - name: Set up .NET for copied request examples if: matrix.group == 'http' - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '8.0.x' - name: Set up Go for copied request examples if: matrix.group == 'http' - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.25.x' cache: false - name: Install the selected navigation browser - if: matrix.group == 'chromium' || matrix.group == 'webkit' + if: matrix.group == 'chromium' || (matrix.group == 'webkit' && vars.CI_PROVIDER != '' && vars.CI_PROVIDER != 'blacksmith') env: BROWSER: ${{ matrix.group }} - run: bunx --no-install playwright install --with-deps "$BROWSER" + run: bunx --no-install playwright install --with-deps --only-shell "$BROWSER" - name: Verify production documentation + id: acceptance env: DOCS_ACCEPTANCE_GROUP: ${{ matrix.group }} run: | + unset PLAYWRIGHT_WEBKIT_WS_ENDPOINT + webkit_state='' mkdir -p apps/docs/test-results FEEDBACK_TLS_DIR=$(mktemp -d) export DOCS_FEEDBACK_TEST_CERT="$FEEDBACK_TLS_DIR/cert.pem" @@ -395,16 +397,27 @@ jobs: DOCS_SERVER_PID=$! finish() { local status=$1 - trap - EXIT + trap - EXIT INT TERM if bash .github/scripts/stop-session.sh "$DOCS_SERVER_PID" "$APP_TAG"; then wait "$DOCS_SERVER_PID" 2>/dev/null || true else - status=1 + if [ "$status" -eq 0 ]; then status=1; fi + fi + if [ -n "$webkit_state" ] && ! bash .github/scripts/playwright-server.sh stop "$webkit_state"; then + echo '::error::Owned WebKit server cleanup failed.' >&2 + if [ "$status" -eq 0 ]; then status=1; fi fi rm -rf "$FEEDBACK_TLS_DIR" exit "$status" } trap 'finish "$?"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + if [ "${{ matrix.group == 'webkit' && (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') }}" = true ]; then + webkit_state="$RUNNER_TEMP/playwright-webkit-server" + PLAYWRIGHT_WEBKIT_WS_ENDPOINT=$(bash .github/scripts/playwright-server.sh start "$webkit_state") + export PLAYWRIGHT_WEBKIT_WS_ENDPOINT + fi for attempt in $(seq 1 60); do if ! kill -0 "$DOCS_SERVER_PID" 2>/dev/null; then echo '::error::Documentation server exited before becoming ready.' @@ -434,6 +447,17 @@ jobs: *) echo "::error::Unknown documentation acceptance group: $DOCS_ACCEPTANCE_GROUP"; STATUS=1 ;; esac finish "$STATUS" + - name: Stop the owned WebKit server + if: always() && matrix.group == 'webkit' && (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && steps.acceptance.outcome != 'skipped' + run: bash .github/scripts/playwright-server.sh stop "$RUNNER_TEMP/playwright-webkit-server" + - name: Upload WebKit server failure logs + if: failure() && matrix.group == 'webkit' && (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: webkit-server-docs + path: ${{ runner.temp }}/playwright-webkit-server/ + if-no-files-found: ignore + retention-days: 14 - name: Upload acceptance regression reports if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b11e5b4f7fd..f9f8d3aeb92 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -79,7 +79,7 @@ jobs: # Database IP restrictions admit the tailnet's app connector; see migrate.yml. - name: Join the tailnet if: vars.CI_DB_TAILNET == 'true' - uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0 + uses: tailscale/github-action@064b10558093f38325e838ddea35486bdc6b1d5c # v4.4.0 with: oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} audience: ${{ secrets.TS_AUDIENCE }} diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index 85fa8dec01d..2988995c3c0 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout code (mirror) if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork - uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1 + uses: useblacksmith/checkout@2b9fe87d796feea153b455ec2f3125b471a7b0e7 # v1.8.2 with: &chart-checkout-options # Chart version checks need full history to compare against the PR base. fetch-depth: 0 @@ -403,7 +403,7 @@ jobs: push-to-registry: true - name: Set up ORAS - uses: oras-project/setup-oras@005458ad77f1c8facd38a094e4af2e69e5607ff4 # v2.0.2 + uses: oras-project/setup-oras@5e2f2482b30b0cb3e14e67f89a80c88c659a7c63 # v2.0.3 # Artifact Hub reads repository metadata from the reserved `artifacthub.io` # tag on the chart's own OCI repository. Pushed on every run, including @@ -511,7 +511,7 @@ jobs: # packages stay reachable no matter which domain serves the index. - name: Run chart-releaser if: steps.pages.outputs.exists == 'true' - uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 + uses: helm/chart-releaser-action@92c6b97fa9f664492a2592e0d1d80bc6bf378957 # v1.9.0 with: charts_dir: helm # Re-running on an already-released version must be a no-op, the same diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index fcad4344b40..860ea110b23 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -87,7 +87,7 @@ jobs: # makes `tailscale up` reject the duplicate flag. - name: Join the tailnet if: vars.CI_DB_TAILNET == 'true' - uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0 + uses: tailscale/github-action@064b10558093f38325e838ddea35486bdc6b1d5c # v4.4.0 with: oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} audience: ${{ secrets.TS_AUDIENCE }} diff --git a/.github/workflows/trigger-dev.yml b/.github/workflows/trigger-dev.yml index c34d6115764..9ce8cbbb7e8 100644 --- a/.github/workflows/trigger-dev.yml +++ b/.github/workflows/trigger-dev.yml @@ -19,6 +19,9 @@ jobs: name: deploy runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 + permissions: + contents: read + id-token: write steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -31,12 +34,22 @@ jobs: provider: ${{ vars.CI_PROVIDER }} node-version: '' + # The deploy syncs an environment listed in `vars.TRIGGER_ENV_SYNC_TARGETS` from the app's + # env-vars secret (see `@sim/runtime-secrets/trigger-env`), which needs read access to that secret. + - name: Configure AWS credentials for the env sync + if: contains(format(',{0},', vars.TRIGGER_ENV_SYNC_TARGETS), ',preview,') + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6 + with: + role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.DEV_AWS_REGION }} + - name: Upload preview version id: deploy working-directory: ./apps/sim env: TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN_PREVIEW || secrets.TRIGGER_ACCESS_TOKEN }} TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} + TRIGGER_ENV_SYNC_TARGETS: ${{ vars.TRIGGER_ENV_SYNC_TARGETS }} run: | set -euo pipefail : "${TRIGGER_ACCESS_TOKEN:?TRIGGER_ACCESS_TOKEN_PREVIEW or TRIGGER_ACCESS_TOKEN must be configured}" diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml new file mode 100644 index 00000000000..02ef9aaea68 --- /dev/null +++ b/.github/workflows/validation.yml @@ -0,0 +1,13 @@ +name: validation + +on: + push: + branches: [main, staging] + +permissions: + contents: read + +jobs: + checks: + name: checks + uses: ./.github/workflows/checks.yml diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 5fab254e1bd..cc4d3c332de 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1708,7 +1708,7 @@ export function DustIcon(props: SVGProps) { return ( @@ -6545,7 +6545,7 @@ export function LitellmIcon(props: SVGProps) { LiteLLM ) { return ( @@ -9901,7 +9901,7 @@ export function ZapierIcon(props: SVGProps) { return ( @@ -9913,7 +9913,7 @@ export function MakeIcon(props: SVGProps) { return ( @@ -9925,7 +9925,7 @@ export function GumloopIcon(props: SVGProps) { return ( @@ -9937,7 +9937,7 @@ export function WorkatoIcon(props: SVGProps) { return ( @@ -9949,7 +9949,7 @@ export function PipedreamIcon(props: SVGProps) { return ( @@ -9961,7 +9961,7 @@ export function TinesIcon(props: SVGProps) { return ( @@ -9973,7 +9973,7 @@ export function StackAIIcon(props: SVGProps) { return ( @@ -9985,7 +9985,7 @@ export function VellumIcon(props: SVGProps) { return ( @@ -10008,7 +10008,7 @@ export function LangflowIcon(props: SVGProps) { return ( @@ -10020,7 +10020,7 @@ export function FlowiseIcon(props: SVGProps) { return ( diff --git a/apps/docs/content/docs/browser-extension/index.mdx b/apps/docs/content/docs/browser-extension/index.mdx new file mode 100644 index 00000000000..8b0d35cfeca --- /dev/null +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -0,0 +1,38 @@ +--- +title: Sim browser extension +description: Ask Sim about the page you’re reading, share a screenshot, and open your team’s Sim Links from Chrome. +--- + +[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) to ask questions about a page without leaving it. Open Chrome’s **Extensions** menu and pin **Sim** to keep it one click away. + +## Ask about a page + +1. Open a web page and click **Sim** in the Chrome toolbar. Sign in to Sim if prompted. +2. In the side panel, select the attachment chip to review the captured text, or use its **×** to remove it. To attach the page again after it changes, use the globe next to the message field. +3. Ask a question and send it. For example, on a release notes page, ask “Which changes affect my team?” + +To ask about a passage, select the text on the page, right-click, and choose **Ask Sim about selection**. You can attach up to three snapshots to compare pages. A snapshot stays as it was when you captured it. Chrome settings pages, other extensions, and pages without readable text can’t be attached. + +In version 0.1.3 or later, open **+ → Reference current page on send** to include the active tab each time you send a message. This is off by default. The setting appears above the workspace list, and Sim remembers your choice in this Chrome profile. The globe shows **Current page** while this is on. If you attached a page or selection yourself, Sim uses those snapshots instead. Turn the setting off in the same menu. + +If Chrome needs access after you switch tabs, your message stays in the composer. Select **Request site access**. If Chrome hasn’t shared the site address with Sim, paste it into **Site address**. Check the site shown on **Allow access**, approve it in Chrome’s permission dialog, then send again. For one-time access, click the Sim toolbar icon on the active tab and retry. Open **+ → Site access** to remove a saved permission. + +To share a screenshot, drag the image file onto the open chat. It uploads to Sim and appears as an attachment you can remove before sending. If a website supplies only an image URL, paste the image itself or use the file picker. + +For faster access, press **Command + Shift + S** on macOS or **Alt + Shift + S** on Windows and Linux. In version 0.1.3 or later, the shortcut attaches highlighted text when you have a selection. It won't substitute the whole page for an unreadable selection. With no selection, it attaches the page or uses your send-time reference setting. On an already-open sidebar, Chrome may keep keyboard focus on the website. Click the message field, or use Chrome’s pane navigation (on Mac, **Command + Option + Up**). + +If Chrome needs access to a new tab, the sidebar shows the shortcut assigned in this Chrome profile, or **Set shortcut** if none is assigned. You can change it under **Extensions → Keyboard shortcuts**. + +## Roll out to a team + +Share the chat extension’s install link with your team. In managed Chrome, an admin can [allow the extension for an organizational unit](https://support.google.com/chrome/a/answer/6306504) so people can install it themselves. + +If your organization has [Sim Links](/search/sim-links), type `sim`, press **Space**, and enter part of a link name for suggestions. To open a shortcut by typing it directly, include the trailing slash, as in `sim/handbook/`. Teams that already use an internal host named `sim` should keep that hostname conflict in mind: the chat extension routes those navigations to Sim. + +In version 0.1.3 or later, **+ → Browser shortcuts** can recover explicit `sim/handbook` searches on Google, Bing, DuckDuckGo, and Yahoo. Enable your provider and approve the site access shown by Chrome. This leaves ordinary searches and your default search engine unchanged. See [shortcut setup and supported inputs](/search/sim-links#open-links-from-the-address-bar). + +## Page access and privacy + +Sim reads a page or selection when you attach it, or when you send a message with **Reference current page** enabled. For repeat use on one site, choose **+ → Site access** to grant or revoke that site’s permission. Granting access doesn't start background capture. The page text goes to the hosted composer when attached and becomes part of the conversation when you send it. + +See [Sim’s browser extension privacy disclosure](https://www.sim.ai/privacy#browser-extension). diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index 959f5809299..655dc78ab90 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -183,7 +183,7 @@ sim files get [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File whose content to read | +| `fileId` | Yes | File whose content to read: its id, or its `files//` VFS path | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index b6f24256769..6d9396c5ba1 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -1761,7 +1761,7 @@ sim files get [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File whose content to read | +| `fileId` | Yes | File whose content to read: its id, or its `files//` VFS path | diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 0e9fe601cfa..4a9220fc06b 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -5,6 +5,7 @@ "./introduction/index", "./getting-started/index", "./desktop/index", + "./browser-extension/index", "---Sim Search---", "search", "---Build---", @@ -28,6 +29,7 @@ "defaultOpen": false, "labels": { "./desktop/index": "Desktop", + "./browser-extension/index": "Browser extension", "search": "Overview", "logs-debugging": "Logs and debugging" } diff --git a/apps/docs/content/docs/search/ask-questions.mdx b/apps/docs/content/docs/search/ask-questions.mdx index 507654e8635..b06a49aabc9 100644 --- a/apps/docs/content/docs/search/ask-questions.mdx +++ b/apps/docs/content/docs/search/ask-questions.mdx @@ -32,7 +32,9 @@ Include the project, topic, or timeframe that matters. If you know where the ans > Find our launch plan in Confluence. Summarize the scope and the launch date, with sources. -Assistant finds and reads information. You can also ask it to [draft content from the findings](/search/ask-questions#draft-from-your-sources); the draft stays in the conversation until you choose where to use it. +Assistant finds and reads information. When your sources don't have the answer and it's likely public, such as a product's download page or a published standard, Assistant can search the public web if your deployment enables web search. It checks your sources first, sends only public terms to the web, and cites web pages the same way as your documents. + +You can also ask it to [draft content from the findings](/search/ask-questions#draft-from-your-sources); the draft stays in the conversation until you choose where to use it. ## Check the sources diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json index 66eea5c9a92..7682fb5c4be 100644 --- a/apps/docs/content/docs/search/meta.json +++ b/apps/docs/content/docs/search/meta.json @@ -6,6 +6,7 @@ "find-content", "ask-questions", "sim-links", + "!people", "troubleshooting", "---Connect sources---", "connect-your-account", diff --git a/apps/docs/content/docs/search/people.mdx.draft b/apps/docs/content/docs/search/people.mdx.draft new file mode 100644 index 00000000000..2c64215c7ff --- /dev/null +++ b/apps/docs/content/docs/search/people.mdx.draft @@ -0,0 +1,88 @@ +--- +title: People +description: Find colleagues, explore reporting lines, and keep your directory in sync +--- + +Open **People** in the organization sidebar to find a colleague or see who reports to whom. People appears when your organization has the feature enabled and you have access to its member directory. Organization owners and admins can also edit and import profiles. + +## Explore the org chart + +The chart opens at **My position**. Your manager appears above you and your direct reports appear below you. Select another person’s card to center the chart on them. To see their peers, select their manager and browse that manager’s direct reports. + +- Use **Find a person** to jump to someone by name, title, or email. +- Select a name in the reporting path to move up the chain. Select the organization name to browse top-level teams. +- Use the report arrows to page through a large team. Use the zoom controls or **Fit chart** to bring the current branch back into view. +- Select the profile arrow on a card to open that person’s details. **View in org chart** centers the chart on them. To share that position with another organization member, copy the page URL. + +When no reporting lines have been recorded, an admin can connect a directory, import people, or add a manager to a profile. If just one person has no reporting lines, their profile remains available, and **Explore a team** opens a connected branch. + +## Find and edit a profile + +Switch to **Directory** to search by name, email, title, department, or location. Use **Filter → Department** to narrow the list. Select a row to open the person’s profile, including their email, department, location, start date, managers, and direct reports. + +Profiles managed by a directory show their source. Change their information in that directory. To update a manually managed profile as an admin, select **Edit**, change the fields, and select **Save**. To change a reporting line, choose an active member of the same organization as the manager. Sim rejects self-reporting and reporting loops. Suspended members are hidden from the directory and chart. + +## Keep People in sync + +An organization admin can open **People → Directory sync** and connect one authoritative directory. Use sync when you want future hires, team moves, manager changes, and departures to appear automatically. + +| Source | How to connect | +| --- | --- | +| **SCIM provisioning** | Enable your organization's [SCIM provisioning connection](/platform/enterprise/scim), then select it in People. Map core `title`, enterprise `department`, and enterprise `manager.value`. The manager value is the manager's SCIM resource ID in the same connection. | +| **Okta** | Enter your Okta organization domain and an API token that can read all intended users. Sim reads active users and resolves managers using the Okta user ID in `profile.managerId`. | +| **Microsoft Entra ID** | In your Microsoft commercial-cloud tenant, register an application, grant Microsoft Graph the **application** permission `User.Read.All`, and obtain admin consent. Enter the tenant ID, application (client) ID, and client secret **value**. Sim obtains a fresh access token for each sync and reads direct reports to build the chart. | + +Select **Connect and sync** to run the first refresh. Sim stores the Okta token or Entra client secret encrypted. Use **Update credentials** to rotate them for the same directory. + +### Who appears + +Okta and Entra can include colleagues who have never signed in to Sim. Directory-only people have profiles and reporting lines, including links to managers who also have no account. Appearing in People grants no login, membership, workspace access, or seat. If someone later joins this organization with the same email address, People shows a single profile. Suspended Sim members are hidden. SCIM uses the people already provisioned through the connected Sim provisioning service. Its account provisioning and deprovisioning rules still apply. + +The directory controls the name, title, department, and reporting line of the people it supplies. Okta and Entra also supply location. SCIM uses an address marked as a work address when one is available. Correct these fields in the source. Removing a manager upstream clears the source reporting line. An active person whose manager is absent from the directory appears without that manager. When an entry leaves the source, its directory-only profile disappears. An active Sim member can still appear with their manually recorded profile. + +### Check a sync or recover from a failure + +**Directory sync** shows when the last refresh succeeded and when the next one is due. Okta and Entra refresh hourly. A large directory can take several scheduler passes. The status shows the number of users read and whether Sim is reading people, resolving reporting lines, or publishing. **Sync now** starts a refresh. **Continue sync** processes more of an unfinished refresh. The scheduler checks every five minutes. + +People keeps the last published chart visible until the full replacement passes validation. If a provider asks Sim to slow down, the status shows when the next attempt is allowed. Sim keeps its place and resumes from the last completed page. A refresh still unfinished after seven days restarts from the source on its next eligible run. An empty directory removes its entries only after a successful refresh. + +SCIM user changes queue a refresh for the next scheduler pass, including changes that arrive while a refresh is running. Sim reads locally provisioned profiles together and publishes their reporting lines in one update. When SCIM deactivates or removes someone, their directory-managed profile is hidden immediately. + +Disabling the SCIM provisioning connection pauses People updates and keeps the last published chart. Re-enable provisioning in organization settings to resume syncing. **Sync now** is unavailable while provisioning is disabled. + +After a failure, check the status for the cause. Correct invalid or duplicate email addresses and reporting loops in the source. Check credentials and directory permissions for an access failure. The status shows when Sim will retry automatically. After correcting a source error you can select **Retry sync** immediately. For a provider cooldown, the button becomes available when the cooldown ends. + +To stop syncing, select **Disconnect**. This removes directory-only profiles and source-managed details. Existing Sim members and their manual profile data remain. Disconnect the current source before changing providers or tenants. + +For self-hosted deployments, enable the cron service in Helm or Docker Compose. If you manage schedules yourself, call `GET /api/cron/people-directory-sync` every five minutes with your configured `CRON_SECRET` as a bearer token. + +## Run a one-time import + +Open **People → Import** and choose a source. Sim matches people to existing active members of this organization by email. Importing updates profile details and reporting lines. Membership and workspace access are managed separately. Use **Directory sync** to include people without Sim accounts or keep future changes up to date. + +| Source | What you need | +| --- | --- | +| **Okta** | Your Okta organization domain, such as `example.okta.com`, and an API token with permission to read users. Sim reads active users and resolves managers from `profile.managerId`. | +| **Microsoft Entra ID** | A Microsoft Graph access token for a work or school account in the Microsoft commercial cloud, with delegated `User.Read.All` permission. Sim reads enabled users and their managers. | +| **SCIM provisioning** | An active [provisioning connection](/platform/enterprise/scim) with accounts already provisioned to Sim. Map core `title`, enterprise `department`, and enterprise `manager.value`. The manager value must be the manager’s SCIM User resource ID in the same connection. | +| **Spreadsheet** | A CSV, TSV, or Excel file with an email column and at least one profile field. Use **Template** for the supported column names. | + +Select **Preview import**, or choose a spreadsheet to preview it. Review the counts and warnings before selecting **Import**. Skipped rows make no changes. If a provider supplies the same email for two accounts, Sim skips both. If a manager cannot be matched, or a proposed reporting line would create a loop, Sim keeps the current manager and can still update other fields. + +Okta and Entra tokens are used for the current import. If Okta, Entra, or provisioned SCIM profile data changes between preview and import, Sim asks you to preview again. Run another import when you want to bring in later directory changes. Missing or empty provider fields preserve the values already in Sim. To clear an old manager, edit the profile or use a spreadsheet. + +## Prepare a spreadsheet + +Put the headers in the first row of the first worksheet. For example, this file updates two members and sets Alina as Rowan’s manager. Replace the sample emails with your members’ addresses. + +```csv +email,title,department,manager_email +alina@example.com,Engineering Manager,Engineering, +rowan@example.com,Product Engineer,Engineering,alina@example.com +``` + +Include only the columns you intend to update. A blank cell in an included column clears the existing value. In the example, the blank manager cell removes Alina’s manager. Omit the entire column to preserve that field for every row. + +The importer also recognizes `mail` and `userPrincipalName` from Entra and `email` and `login` from Okta. It falls back to the sign-in address when the primary email cell is blank. Use `YYYY-MM-DD` for a start date. + +Files can be up to 5 MiB, with at most 100 named columns and 10,000 data rows. The 10,000-row limit applies to one-time imports. Use **Directory sync** for a larger provider directory, or split a spreadsheet into smaller files and preview each one separately. diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index f45b2fd980b..3520c7c0eac 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -15,15 +15,29 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` ## Open links from the address bar -With the Sim Chrome extension, type `sim/handbook` in the address bar. To find a link, type `sim`, press **Space**, and start typing its name: matching links appear as you type, most opened first. Unlisted links aren't suggested. +In Sim Cloud, open **Sim Links → Browser shortcuts** to install [Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile. Type `sim` in Chrome’s address bar, press **Space**, and enter part of a link name. Chrome shows matching links, most opened first. Unlisted links can still open by name. + +To open links as `sim/handbook`, select **Enable sim/ shortcuts** in that dialog once, using the Chrome profile where Sim is installed. This opens your Sim Links directory in a new tab and lets Chrome remember the short host in that profile. If Chrome still sends a shortcut to search, enter `http://sim/handbook` or `sim/handbook/` with a trailing slash. Use **Copy link** in Sim to share an address that also works without the extension. + +In Sim for Chrome 0.1.3 or later, open the extension sidebar’s **+ → Browser shortcuts** and enable your search provider to recover `sim/handbook` when Chrome treats it as a search. Approve access to the exact sites shown. This is optional and supports Google (`google.com`), Bing, DuckDuckGo, and Yahoo. Sim remembers the choice in this Chrome profile. Ordinary searches still use your search engine; its default is unchanged. + +Recovery matches a whole `sim/` shortcut, including simple slash-separated values such as `sim/jira/123`. For values containing spaces, query parameters, unsupported search domains, or a managed browser that blocks site access, use `sim` + **Space** or the full `https://www.sim.ai/sim/` address. Turn recovery off in **Browser shortcuts**; this keeps the site permission, which you can remove through **Site access** or Chrome’s extension settings. + +If `sim` + **Space** sends link names to your search engine even when the address bar shows **Sim**, a Chrome site search may already use the `sim` shortcut. Enter `http://sim/handbook` to open the link directly. To restore suggestions, temporarily turn off the Sim extension, rename the old shortcut in `chrome://settings/searchEngines`, then turn Sim back on. If your organization manages the shortcut, ask your administrator to rename it. Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead: 1. Open Chrome **Settings → Search engine → Manage search engines and site search**. 2. Next to **Site search**, select **Add**. -3. Enter `Sim` as the name, `sim` as the shortcut, and `https://www.sim.ai/sim/%s` as the URL. +3. Enter `Sim` as the name, `sim` as the shortcut, and `https://www.sim.ai/sim/%s` as the URL. On a self-hosted deployment, use your own Sim origin. -Type `sim`, press **Space**, then type a link name and press **Enter**. Chrome administrators can add the shortcut for everyone with the `SiteSearchSettings` policy. +Type `sim`, press **Space**, then type a link name and press **Enter**. For managed profiles without the extension, administrators can distribute the shortcut through Chrome’s [site search policy](https://chromeenterprise.google/policies/site-search-settings/) using `https://www.sim.ai/sim/{searchTerms}` as its URL template. + +### Show favorites in regular Chrome suggestions + +In Sim, choose **Copy link** for a favorite. In Chrome’s bookmark manager (`chrome://bookmarks`), add a bookmark with that URL and name it after the shortcut, such as `sim/handbook`. Chrome can suggest that bookmark when you type `sim/handb` or `handbook` in the address bar. Administrators can distribute selected team favorites through Chrome’s [managed bookmarks policy](https://chromeenterprise.google/policies/managed-bookmarks/). + +Organization admins can also open **Browser shortcuts → Team bookmarks**, choose listed links, and select **Export bookmarks**. The downloaded JSON contains a folder to merge into Chrome’s managed bookmarks policy. Its links resolve in the exporting organization, so a bookmark shared outside the team cannot open another team's same-name destination. ## Create a link @@ -31,6 +45,8 @@ Type `sim`, press **Space**, then type a link name and press **Enter**. Chrome a 2. Enter the **Destination URL** and a **Name**. Names use letters, numbers, and `. : @ & ~ - _`; spaces become hyphens as you type. 3. Optionally add a **Description**, then select **Create**. +With Sim for Chrome 0.1.3 or later, right-click an HTTP or HTTPS link on a page and choose **Create Sim Link**. Check the destination and organization, enter a name, then choose **Create**. Sim strips query parameters and fragments from the initial destination. You can edit the address before saving. Closing the draft creates nothing. + To share a link, right-click it and select **Copy link**. ## Add values to a link diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 0b741d9516c..2fcff52693b 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2048,13 +2048,12 @@ "name": "fileId", "in": "path", "required": true, - "description": "File identifier.", + "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "maxLength": 4096, + "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike." } }, { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index aea9b3fa5c8..7768b388293 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -549,6 +549,7 @@ "fieldIssues": [], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] } } @@ -705,8 +706,20 @@ "agent-1": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" }, "lint": { - "sources": [], - "sinks": [], + "sources": [ + { + "blockId": "start-1", + "blockName": "Start", + "blockType": "start_trigger" + } + ], + "sinks": [ + { + "blockId": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77", + "blockName": "Triage", + "blockType": "agent" + } + ], "orphanBlocks": [], "emptyOutgoingPorts": [], "invalidBranchPorts": [], @@ -722,9 +735,12 @@ ], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] }, - "warnings": [], + "warnings": [ + "Workflow lint found issues (Blocks missing required fields: 1). The findings are in `lint`." + ], "needsRedeployment": true, "dryRun": false } @@ -2655,6 +2671,7 @@ "fieldIssues": [], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] } } @@ -8813,6 +8830,63 @@ }, "description": "Table block `filter` and `order` fields checked against the bound table's live schema that name no column (nor the implicit `id`, `createdAt`, `updatedAt`). Such a run fails inside the block's error edge. A filter holding a `` reference, or one that is not JSON, is not checked." }, + "manualRunIssue": { + "anyOf": [ + { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": ["no-trigger", "multiple-api-triggers", "trigger-not-connected"], + "description": "Why the run is refused." + }, + "message": { + "type": "string", + "description": "The error the manual run raises." + }, + "blockId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Trigger the run would start from, when the problem is that trigger." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of that trigger." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of that trigger." + } + }, + "required": ["reason", "message", "blockId", "blockName", "blockType"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Why a manual run from the editor's Run button would be refused before it starts: no enabled trigger it can start from, more than one API trigger, or a selected trigger with no outgoing connection. The API run endpoints do not apply these checks. `null` when no manual-run issue is reported: the run can begin, only a chat trigger can start the workflow, or the graph is empty (reported in `notes` instead)." + }, "notes": { "type": "array", "items": { @@ -8831,6 +8905,7 @@ "fieldIssues", "unresolvedReferences", "tableFieldIssues", + "manualRunIssue", "notes" ], "additionalProperties": false, @@ -8897,6 +8972,7 @@ "fieldIssues": [], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] } } @@ -9461,7 +9537,7 @@ "items": { "type": "string" }, - "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence and, when `lint` has findings, one line counting them by category, so a caller that reads only `warnings` still learns they exist. The findings themselves are in `lint`. Empty when there is nothing to report." }, "needsRedeployment": { "type": "boolean", @@ -9564,8 +9640,20 @@ "agent-1": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" }, "lint": { - "sources": [], - "sinks": [], + "sources": [ + { + "blockId": "start-1", + "blockName": "Start", + "blockType": "start_trigger" + } + ], + "sinks": [ + { + "blockId": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77", + "blockName": "Triage", + "blockType": "agent" + } + ], "orphanBlocks": [], "emptyOutgoingPorts": [], "invalidBranchPorts": [], @@ -9581,9 +9669,12 @@ ], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] }, - "warnings": [], + "warnings": [ + "Workflow lint found issues (Blocks missing required fields: 1). The findings are in `lint`." + ], "needsRedeployment": true, "dryRun": false } @@ -12554,6 +12645,7 @@ "fieldIssues": [], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] } } diff --git a/apps/docs/scripts/test-navigation-e2e.ts b/apps/docs/scripts/test-navigation-e2e.ts index e73c1f29036..b817e80c227 100644 --- a/apps/docs/scripts/test-navigation-e2e.ts +++ b/apps/docs/scripts/test-navigation-e2e.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { type Browser, @@ -30,6 +31,16 @@ const canonicalOrigin = new URL(process.env.NEXT_PUBLIC_DOCS_URL ?? 'https://doc const engine = process.env.DOCS_NAVIGATION_BROWSER ?? 'chromium' if (engine !== 'chromium' && engine !== 'webkit') throw new Error('DOCS_NAVIGATION_BROWSER must be chromium or webkit') +const webkitEndpoint = process.env.PLAYWRIGHT_WEBKIT_WS_ENDPOINT +if (webkitEndpoint !== undefined) { + if (engine !== 'webkit' || !/^ws:\/\/127\.0\.0\.1:[1-9]\d*\/$/.test(webkitEndpoint)) + throw new Error('A remote endpoint requires explicit WebKit selection and a loopback URL') + new URL(webkitEndpoint) + if ( + toRecord(createRequire(import.meta.url)('@playwright/test/package.json')).version !== '1.64.1' + ) + throw new Error('The remote WebKit image requires Playwright 1.64.1') +} const results: Array<{ name: string status: 'passed' | 'failed' | 'unavailable' @@ -162,7 +173,10 @@ async function verifyClipboard( let browser: Browser | undefined try { - browser = await (engine === 'webkit' ? webkit : chromium).launch() + browser = + webkitEndpoint === undefined + ? await (engine === 'webkit' ? webkit : chromium).launch() + : await webkit.connect(webkitEndpoint, { timeout: 180_000 }) const feedbackBrowser = browser await check('Feedback forwards votes and preserves unchanged retry identity', async () => { const cert = process.env.DOCS_FEEDBACK_TEST_CERT diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md index be04a71d45d..b2fd6ea2244 100644 --- a/apps/extension/DESIGN.md +++ b/apps/extension/DESIGN.md @@ -56,7 +56,7 @@ other workspace resources or browser-control tools. - Required browser capabilities: side panel, scripting, active tab, context menus, and short-lived session storage for selection handoff. Required host access is limited to the configured Sim origin and to `http(s)://sim/*`, which exists only so - one static redirect rule can send a typed `sim/` to the Sim link resolver. + one static redirect rule can send top-level navigations to that host to the Sim link resolver. - Other HTTP(S) origins are optional grants requested for the current site from a user gesture. Each grant can be revoked. Keeping the panel open is not a grant to read every subsequently selected tab. @@ -68,7 +68,7 @@ other workspace resources or browser-control tools. configured Sim origin with the signed-in session to suggest matching Sim links, unless it contains a space (a search) or is longer than a link name can be. Page content is never sent, and unlisted links are never suggested. -- No always-running page reader. Extraction is tied to an explicit attachment action. +- Page extraction runs on an explicit attachment action or a user-initiated composer Send while Reference current page is enabled. The preference persists locally. Send-time captures are scoped to the request and conversation; navigation, denied access or timeout cancels the send. - Page bytes are not written to extension local/sync storage. Temporary context-menu handoff uses trusted-context session storage, expiration and navigation cleanup. - Sent snapshots become chat data and follow Sim's chat retention and deletion diff --git a/apps/extension/README.md b/apps/extension/README.md index 93a7729c8b8..28584cfb483 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -2,6 +2,10 @@ The Chrome extension attaches an explicitly captured page or selection to the existing Sim chat. The native side panel hosts the authenticated `/extension` app surface. Chat opens in the user's organization when available; choosing a workspace is optional. Members without access to Home use the organization's Assistant. Chat rendering, execution, permissions, billing, and history belong to Sim and Mothership. +[User and administrator guide](https://docs.sim.ai/browser-extension) + +The keyboard shortcut prefers highlighted text and preserves the draft when a selection cannot be read. Chrome’s **Extensions → Keyboard shortcuts** page controls the binding per profile. A link’s context menu opens the existing Sim Links creation form with a reviewed destination; only selecting **Create** writes a link. + ## Build and load From the repository root: @@ -26,18 +30,21 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app ## Data and permissions - Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*`. -- Typing `sim/` in the address bar opens the organization's [Sim link](https://docs.sim.ai/search/sim-links). A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` to `/sim/*` with the path and query unchanged. The `sim` host permission exists only for that rule: every navigation to that host is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` should not install this build, since its links would be redirected to Sim. -- The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. It is the extension's only network request: the background worker sends the text typed before the first `/` or `?`, and nothing else, unless that text contains a space (a search) or is longer than a link name can be, to `/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested. +- Type `sim`, press Space, enter the name, and press Enter to open an organization's [Sim link](https://docs.sim.ai/search/sim-links). Chrome can treat bare `sim/` as a search before the redirect rule runs. Typing `http://sim/` once lets Chrome learn the short host in that profile. A trailing slash (`sim//`) also selects navigation without relying on that history. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` to `/sim/*` with the path and query unchanged. The `sim` host permission exists only for that rule: every navigation to that host is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` should not install this build, since its links would be redirected to Sim. +- The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. For these suggestions, the background worker sends the text typed before the first `/` or `?`, and nothing else, unless that text contains a space (a search) or is longer than a link name can be, to `/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested. +- **+ → Browser shortcuts** separately opts into recovery for exact Google, Bing, DuckDuckGo, or Yahoo HTTPS search hosts. After a direct Chrome permission grant, dynamic request rules redirect only top-level GET requests with one query parameter containing a whole `sim/` shortcut. Only that value reaches `/sim-links/recover`, which validates it and redirects to the existing authenticated resolver. Ordinary search terms, additional search parameters, subframes, and unsupported inputs are not forwarded. Chrome applies these rules without an extension search-URL observer. Turning a provider off removes its rules while preserving site access; revoking access clears its recovery choice. This does not enable page reference or change the default search provider. - Optional HTTP/HTTPS site access is requested only by a menu action naming that site. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. - Page capture runs on demand in Chrome's isolated world. It excludes editable fields, inputs, hidden elements, scripts, and embedded documents. It reads only the top frame. PDF viewers and canvas documents may have no accessible text. - Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation. - Source URLs omit credentials, query parameters, and fragments. Human-readable text can still contain sensitive information; it is the user-selected material they review and send into their conversation. - Snapshots stay in memory until handed to Sim and acknowledged or expired after 30 seconds. Extension storage contains only a short-lived intent with a tab/document identity and a SHA-256 URL digest, never raw URLs, captured text, or credentials. Closing a tab or navigating invalidates its pending intent. Sim applies its normal conversation retention once a message is sent. - No browser history, bookmarks, cookie access, debugger access, remote extension JavaScript, arbitrary network proxy, or background content collection is requested. Incognito use is disabled. -- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, or the native site-access dialog. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned dialog. +- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, or the native site-access and browser-shortcut dialogs. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned dialog. Shortcut settings are advertised separately so older installed shells keep working without the new action. - Captures and queued toolbar intents are scoped to the receiving conversation. Changing conversation, workspace, or account discards an unfinished handoff. Captured attachments remain explicit snapshots when the source tab changes. - The complete hosted document, including portaled source dialogs and menus, is blocked from session replay. Clipboard writing is delegated only to the configured Sim origin. Clipboard reading and microphone capture are not delegated; voice input remains available in the main app. +With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. The extension stores only the preference in local storage. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. When Chrome hides the active address, **Site access** lets the user enter a site and confirm its origin in Chrome’s permission dialog. The browser grants the selected scheme, host and port. Uploaded screenshot files stay in the hosted app’s normal attachment flow. + ## Verification ```sh diff --git a/apps/extension/package.json b/apps/extension/package.json index e4fa5911ca2..577d69f3edf 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@sim/extension", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/apps/extension/scripts/build.ts b/apps/extension/scripts/build.ts index 626c4fc7b7f..7bd61621109 100644 --- a/apps/extension/scripts/build.ts +++ b/apps/extension/scripts/build.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { build } from 'esbuild' import postcss from 'postcss' import loadPostcssConfig from 'postcss-load-config' +import packageJson from '../package.json' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const origin = new URL(process.env.SIM_EXTENSION_ORIGIN ?? 'https://www.sim.ai') @@ -89,8 +90,8 @@ for (const [size, file] of [ await cp(resolve(root, '../sim/public/favicon', file), resolve(dist, `icon-${size}.png`)) } /** - * Chrome navigates a typed `sim/` to `http://sim/`; this rewrites it to the Sim link - * resolver on the configured origin, keeping the rest of the path and query verbatim. + * Only requests to the short host reach this rule. Chrome can classify bare `sim/` as a + * search before navigation. Preserve the path and query for actual short-host navigations. */ const simLinkRules = [ { @@ -107,7 +108,7 @@ await writeFile( { manifest_version: 3, name: development ? 'Sim (Development)' : 'Sim', - version: '0.1.2', + version: packageJson.version, description: "Ask Sim about the page you are reading, and open your organization's sim/ links from the address bar.", minimum_chrome_version: '120', @@ -132,7 +133,7 @@ await writeFile( commands: { 'open-sim': { suggested_key: { default: 'Alt+Shift+S', mac: 'Command+Shift+S' }, - description: 'Open Sim and attach the current page', + description: 'Ask Sim about selected text or the current page', }, }, content_security_policy: { diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index cb63a401c40..8d1da01cd73 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -7,7 +7,11 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { createElement } from 'react' import { type BrowserContext, chromium, expect, type Frame, type Page } from '@playwright/test' -import { pageSnapshotSchema } from '@sim/browser-extension-protocol' +import { + type AppToExtensionMessage, + extensionToAppMessageSchema, + pageSnapshotSchema, +} from '@sim/browser-extension-protocol' import { SecretReveal } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -43,6 +47,17 @@ const fixture = Bun.serve({ window.addEventListener('message', event => { if (event.source !== parent || !event.origin.startsWith('chrome-extension://')) return; if (event.data.type === 'e2e-barrier') document.body.dataset.barrierId = event.data.id; + if (event.data.type === 'connected') { + document.body.dataset.connected = event.data.sessionId; + document.body.dataset.preferenceAtConnect = document.body.dataset.reference ?? 'unknown'; + } + if (event.data.sessionId === document.body.dataset.sessionId) { + if (event.data.type === 'reference-preference') document.body.dataset.reference = String(event.data.enabled); + if (event.data.type === 'send-context' || event.data.type === 'send-context-error') { + document.body.dataset.referenceReply = JSON.stringify(event.data); + document.body.dataset.referenceRequest = event.data.requestId; + } + } if (event.data.type === 'context' && event.data.sessionId === document.body.dataset.sessionId) { if (document.body.dataset.ignoreCaptures) return; received.push(event.data.snapshot); @@ -68,6 +83,14 @@ const fixture = Bun.serve({ ) }, }) +const searchProviders = [ + { id: 'google', hosts: ['www.google.com', 'google.com'], path: '/search', parameter: 'q' }, + { id: 'bing', hosts: ['www.bing.com', 'bing.com'], path: '/search', parameter: 'q' }, + { id: 'duckduckgo', hosts: ['duckduckgo.com', 'www.duckduckgo.com'], path: '/', parameter: 'q' }, + { id: 'yahoo', hosts: ['search.yahoo.com'], path: '/search', parameter: 'p' }, +] as const +const searchRequests: { url: string; method: string }[] = [] +let searchFixture: ReturnType | undefined const temporary = await mkdtemp(resolve(tmpdir(), 'sim-extension-e2e-')) const extensionPath = resolve(temporary, 'extension') let context: BrowserContext | undefined @@ -111,6 +134,50 @@ try { ) assert(!existsSync(rejectedOutput), 'Invalid key material reached build output') }) + const searchKey = resolve(temporary, 'search-key.pem') + const searchCertificate = resolve(temporary, 'search-cert.pem') + const certificate = spawnSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-days', + '1', + '-keyout', + searchKey, + '-out', + searchCertificate, + '-subj', + '/CN=extension-search.test', + ], + { encoding: 'utf8', timeout: 10_000 } + ) + assert(certificate.status === 0, 'Could not create the isolated search fixture certificate') + searchFixture = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + tls: { key: await readFile(searchKey), cert: await readFile(searchCertificate) }, + fetch(request) { + const path = new URL(request.url).pathname + if (path === '/' || path === '/search') + searchRequests.push({ url: request.url, method: request.method }) + return new Response( + 'Local search fixture

Search fixture

', + { + headers: { 'content-type': 'text/html' }, + } + ) + }, + }) + const searchHosts = searchProviders.flatMap((provider) => [...provider.hosts]) + const searchPort = searchFixture.port + const fixtureSearchHosts = [...searchHosts, 'm.google.com', 'www.google.com.evil.test'] + const resolverRules = fixtureSearchHosts + .map((host) => `MAP ${host} 127.0.0.1:${searchPort}`) + .join(', ') const origin = `http://127.0.0.1:${fixture.port}` const pageOrigin = `http://localhost:${fixture.port}` const built = spawnSync(process.execPath, ['scripts/build.ts', `--outdir=${extensionPath}`], { @@ -120,13 +187,23 @@ try { }) assert(built.status === 0, `Extension build failed: ${built.stderr}`) const manifest = JSON.parse(await readFile(resolve(extensionPath, 'manifest.json'), 'utf8')) - // The test package pregrants its disposable fixture site, equivalent to an accepted site prompt. - manifest.host_permissions.push(`${pageOrigin}/*`) + // Pregrants isolate routing from consent; approved search hosts resolve to the local fixture. + manifest.host_permissions.push( + `${pageOrigin}/*`, + ...fixtureSearchHosts.map((host) => `https://${host}/*`) + ) await writeFile(resolve(extensionPath, 'manifest.json'), JSON.stringify(manifest)) context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), { channel: 'chromium', headless: true, - args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`], + ignoreHTTPSErrors: true, + args: [ + `--host-resolver-rules=${resolverRules}`, + '--no-proxy-server', + '--enable-unsafe-extension-debugging', + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], }) const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker')) context.setDefaultTimeout(15_000) @@ -188,14 +265,56 @@ try { const snapshots = async () => pageSnapshotSchema.array().parse(JSON.parse(await app.locator('#received').innerText())) let receiverSessionId = generateId() + const post = (message: AppToExtensionMessage) => + app.evaluate(({ message, origin }) => parent.postMessage(message, origin), { + message, + origin: `chrome-extension://${extensionId}`, + }) + const setReference = async (enabled: boolean) => { + await post({ + channel: 'sim-extension', + version: 1, + sessionId: receiverSessionId, + type: 'set-reference-preference', + enabled, + }) + await expect(app.locator('body')).toHaveAttribute('data-reference', String(enabled)) + } + const referenceRequest = () => + ({ + channel: 'sim-extension', + version: 1, + sessionId: receiverSessionId, + type: 'capture-for-send', + requestId: generateId(), + }) as const + const readReply = async (requestId: string) => { + await expect(app.locator('body')).toHaveAttribute('data-reference-request', requestId) + const raw = await app.locator('body').getAttribute('data-reference-reply') + assert(raw, 'No reply for the current reference request') + const reply = extensionToAppMessageSchema.parse(JSON.parse(raw)) + assert( + (reply.type === 'send-context' || reply.type === 'send-context-error') && + reply.requestId === requestId, + 'Unexpected reference reply' + ) + return reply + } + const reference = async () => { + const message = referenceRequest() + await post(message) + return readReply(message.requestId) + } await app.evaluate((sessionId) => { document.body.dataset.sessionId = sessionId }, receiverSessionId) - const ready = () => { + const ready = async () => { receiverReady = true - return app.evaluate( + await app.evaluate( ({ targetOrigin, sessionId }) => { document.body.dataset.sessionId = sessionId + delete document.body.dataset.connected + delete document.body.dataset.reference parent.postMessage( { channel: 'sim-extension', version: 1, type: 'ready', sessionId }, targetOrigin @@ -203,6 +322,7 @@ try { }, { targetOrigin: `chrome-extension://${extensionId}`, sessionId: receiverSessionId } ) + await expect(app.locator('body')).toHaveAttribute('data-connected', receiverSessionId) } const pendingIntent = () => worker.evaluate(async () => { @@ -240,11 +360,16 @@ try { } await check( - 'A typed sim/ link opens the Sim link resolver on the configured origin', + 'HTTP and HTTPS navigations to the sim host reach the configured resolver', async () => { const tab = await page.context().newPage() for (const scheme of ['http', 'https']) { - for (const path of ['handbook', 'jira/123?view=board', 'jira/a%2Fb?view=board%20view']) { + for (const path of [ + 'handbook', + 'handbook/', + 'jira/123?view=board', + 'jira/a%2Fb?view=board%20view', + ]) { await tab.goto(`${scheme}://sim/${path}`) assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`) } @@ -253,6 +378,74 @@ try { } ) + await check( + 'Link drafts enforce the saved URL limit after removing private URL parts', + async () => { + const prefix = 'https://links.controls.test/' + const cases = [ + { + name: 'A canonical 2048-character destination remains usable', + input: `${prefix}${'a'.repeat(2048 - prefix.length)}`, + expected: `${prefix}${'a'.repeat(2048 - prefix.length)}`, + }, + { + name: 'A canonical 2049-character destination does not open an unsavable draft', + input: `${prefix}${'a'.repeat(2049 - prefix.length)}`, + expected: null, + }, + { + name: 'URL serialization cannot expand a short Unicode input beyond the saved limit', + input: `${prefix}${'é'.repeat(400)}`, + expected: null, + }, + { + name: 'A removable long private query does not reject a short destination', + input: `${prefix}safe?token=${'s'.repeat(3000)}#private-section`, + expected: `${prefix}safe`, + }, + ] + const failures: string[] = [] + for (const entry of cases) { + await page.bringToFront() + const opened = await worker.evaluate(async (linkUrl) => { + const before = await chrome.tabs.query({}) + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + if (!tab) throw new Error('Missing context-menu source tab') + const clicked = chrome.contextMenus.onClicked as typeof chrome.contextMenus.onClicked & { + dispatch: (info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) => void + } + clicked.dispatch({ menuItemId: 'create-sim-link', linkUrl, editable: false }, tab) + const created = (await chrome.tabs.query({})).filter( + (candidate) => !before.some((existing) => existing.id === candidate.id) + ) + const urls = created.map((candidate) => candidate.pendingUrl || candidate.url || '') + for (const candidate of created) { + if (candidate.id !== undefined) await chrome.tabs.remove(candidate.id) + } + return urls + }, entry.input) + if (entry.expected === null) { + if (opened.length !== 0) failures.push(`${entry.name}: opened ${opened.length} draft`) + continue + } + if (opened.length !== 1) { + failures.push(`${entry.name}: opened ${opened.length} drafts`) + continue + } + const draft = new URL(opened[0]) + if ( + draft.origin !== origin || + draft.pathname !== '/extension/create-link' || + draft.search !== '' || + new URLSearchParams(draft.hash.slice(1)).get('url') !== entry.expected + ) + failures.push(`${entry.name}: incorrect private-safe destination handoff`) + } + assert(failures.length === 0, failures.join('\n')) + await page.bringToFront() + } + ) + await check('Capture stays local until the trusted hosted frame is ready', async () => { await capture() await panel.getByRole('status').filter({ hasText: 'Waiting for Sim' }).waitFor() @@ -339,14 +532,28 @@ try { } assert(snapshot.url === `${pageOrigin}/ticket`, 'Source URL contains query or fragment') }) - await check('Selection attaches only the selected excerpt', async () => { + await check('A focused read-only custom element attaches only the selected excerpt', async () => { await page.evaluate(() => { const selected = document.getElementById('selected') if (!selected) throw new Error('Missing selection fixture') + customElements.define('readable-article', class extends HTMLElement {}) + const host = document.createElement('readable-article') + host.tabIndex = 0 + selected.replaceWith(host) + host.append(selected) + host.focus() const range = document.createRange() range.selectNodeContents(selected) - window.getSelection()?.removeAllRanges() - window.getSelection()?.addRange(range) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + if ( + document.activeElement !== host || + host.isContentEditable || + host.shadowRoot || + selection?.toString() !== selected.textContent + ) + throw new Error('Fixture did not focus and select ordinary custom-element text') }) await capture('Selection') await app.waitForFunction( @@ -358,6 +565,7 @@ try { snapshot.text === 'The customer needs a replacement by Friday.', 'Selection widened beyond the user excerpt' ) + await page.locator('readable-article').evaluate((host) => host.replaceWith(...host.childNodes)) }) await check('A focused child frame cannot substitute an old parent-page selection', async () => { await page.evaluate(() => { @@ -399,6 +607,45 @@ try { .waitFor() assert((await snapshots()).length === 2, 'Editable content was transmitted') }) + await check( + 'A selection inside a closed shadow field cannot become a whole-page reference', + async () => { + await setReference(true) + await page.evaluate(() => { + const host = document.createElement('div') + document.body.appendChild(host) + const field = document.createElement('input') + field.type = 'password' + field.value = 'PRIVATE_SHADOW_SENTINEL' + host.attachShadow({ mode: 'closed' }).append(field) + field.focus() + field.select() + }) + await capture('Selection') + await expect(panel.getByRole('status')).toContainText('outside an editable or embedded field') + const refused = await reference() + assert( + refused.type === 'send-context-error' && refused.reason === 'selection', + 'A private selection fell back to the surrounding page' + ) + assert((await snapshots()).length === 2, 'Private selection produced an attachment') + const next = await page.context().newPage() + try { + await next.goto(`${pageOrigin}/after-selection`) + await next.bringToFront() + const accepted = await reference() + assert( + accepted.type === 'send-context' && + accepted.snapshot.url === `${pageOrigin}/after-selection`, + 'The previous failed selection blocked a different tab' + ) + } finally { + await next.close() + await page.bringToFront() + await setReference(false) + } + } + ) await check('Large multilingual pages remain bounded and mark truncation', async () => { await page.evaluate(() => { document.body.textContent = '界'.repeat(200_000) @@ -601,6 +848,7 @@ try { await check( 'Composer capture requests require a current user gesture and conversation', async () => { + await setReference(true) const before = (await snapshots()).length await panel.evaluate(() => { let queries = 0 @@ -659,6 +907,15 @@ try { expression: `parent.postMessage(${JSON.stringify(action)}, ${JSON.stringify(`chrome-extension://${extensionId}`)})`, userGesture: false, }) + const unactivated = referenceRequest() + await cdp.send('Runtime.evaluate', { + expression: `parent.postMessage(${JSON.stringify(unactivated)}, ${JSON.stringify(`chrome-extension://${extensionId}`)})`, + userGesture: false, + }) + assert( + (await readReply(unactivated.requestId)).type === 'send-context-error', + 'Send captured the page without a user gesture' + ) await expect .poll(async () => { const { result } = await parentCdp.send('Runtime.evaluate', { @@ -691,6 +948,7 @@ try { ) await cdp.detach() await parentCdp.detach() + await setReference(false) } ) await check('Ordinary Sim and encoded remote pages attach without sensitive fields', async () => { @@ -823,6 +1081,441 @@ try { assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`) } ) + await check( + 'Only the latest conversation connects, after receiving the saved reference preference', + async () => { + await setReference(true) + await panel.evaluate(() => { + const original = chrome.storage.local.get.bind(chrome.storage.local) + chrome.storage.local.get = (async (keys: string) => { + chrome.storage.local.get = original + const result = await original(keys) + document.body.dataset.preferenceRead = 'held' + await new Promise((resolve) => + document.addEventListener('release-preference', () => resolve(), { once: true }) + ) + document.body.dataset.preferenceRead = 'finished' + return result + }) as typeof chrome.storage.local.get + }) + await app.evaluate( + ({ origin, sessionId }) => { + delete document.body.dataset.reference + delete document.body.dataset.connected + document.body.dataset.sessionId = sessionId + parent.postMessage( + { channel: 'sim-extension', version: 1, type: 'ready', sessionId }, + origin + ) + }, + { origin: `chrome-extension://${extensionId}`, sessionId: generateId() } + ) + await expect(panel.locator('body')).toHaveAttribute('data-preference-read', 'held') + await settleMessages() + assert( + (await app.locator('body').getAttribute('data-connected')) === null, + 'Shell connected before its preference was known' + ) + receiverSessionId = generateId() + await ready() + await expect(app.locator('body')).toHaveAttribute('data-preference-at-connect', 'true') + await panel.evaluate(() => document.dispatchEvent(new Event('release-preference'))) + await expect(panel.locator('body')).toHaveAttribute('data-preference-read', 'finished') + await settleMessages() + await expect(app.locator('body')).toHaveAttribute('data-connected', receiverSessionId) + await setReference(false) + } + ) + await check('A failed preference read keeps manual attachment available', async () => { + await panel.evaluate(() => { + const get = chrome.storage.local.get.bind(chrome.storage.local) + chrome.storage.local.get = (() => { + chrome.storage.local.get = get + return Promise.reject(new Error('Storage unavailable')) + }) as typeof chrome.storage.local.get + }) + receiverSessionId = generateId() + await ready() + await expect(app.locator('body')).toHaveAttribute('data-connected', receiverSessionId) + await expect(app.locator('body')).toHaveAttribute('data-preference-at-connect', 'false') + await expect(panel.getByRole('status')).toContainText('Attach the page manually') + const before = (await snapshots()).length + await page.goto(`${origin}/workspace/example`) + await page.bringToFront() + await capture() + await expect.poll(async () => (await snapshots()).length).toBe(before + 1) + }) + await check( + 'Toolbar access on a new site allows an explicit retry without pinning it in Reference mode', + async () => { + const browser = page.context().browser() + assert(browser, 'Missing Chromium browser') + const cdp = await browser.newBrowserCDPSession() + try { + await setReference(true) + await page + .context() + .route('https://reference-source.example/**', (route) => + route.fulfill({ contentType: 'text/html', body: '

NEW_REFERENCE_SOURCE

' }) + ) + await page.goto('https://reference-source.example/new-source?token=PRIVATE#fragment') + await page.bringToFront() + assert((await reference()).type === 'send-context-error', 'An ungranted site was captured') + await panel.evaluate(() => { + let intents = 0 + document.body.dataset.toolbarIntents = '0' + chrome.storage.onChanged.addListener((changes, area) => { + if ( + area === 'session' && + Object.entries(changes).some( + ([key, change]) => key.startsWith('captureIntent:') && change.newValue + ) + ) + document.body.dataset.toolbarIntents = String(++intents) + }) + }) + const targets = await cdp.send('Target.getTargets', { filter: [{ type: 'tab' }] }) + const target = targets.targetInfos.find((item) => item.url === page.url()) + assert(target, 'Missing toolbar action target') + const action = { id: extensionId, targetId: target.targetId } + await cdp.send('Extensions.triggerAction', action) + const retried = await reference() + assert( + retried.type === 'send-context' && retried.snapshot.text.includes('NEW_REFERENCE_SOURCE'), + 'Toolbar access did not allow the explicit retry' + ) + assert( + retried.snapshot.url === 'https://reference-source.example/new-source', + 'Reference retained a private URL parameter' + ) + await expect(panel.locator('body')).toHaveAttribute('data-toolbar-intents', '0') + await setReference(false) + await cdp.send('Extensions.triggerAction', action) + await expect(panel.locator('body')).toHaveAttribute('data-toolbar-intents', '1') + } finally { + await cdp.detach() + await page.context().unroute('https://reference-source.example/**') + } + } + ) + await check( + 'Switching away and back during extraction discards the send-time reference', + async () => { + await setReference(true) + await page.goto(`${pageOrigin}/switch-source`) + await page.bringToFront() + await panel.evaluate(() => { + const original = chrome.scripting.executeScript.bind(chrome.scripting) + // Hold the real Chrome result while the source changes, even on a fast runner. + chrome.scripting.executeScript = (async ( + injection: chrome.scripting.ScriptInjection + ) => { + chrome.scripting.executeScript = original + const result = await original(injection) + document.body.dataset.extraction = 'complete' + await new Promise((resolve) => + document.addEventListener('release-extraction', () => resolve(), { once: true }) + ) + return result + }) as typeof chrome.scripting.executeScript + }) + const pending = reference() + await expect(panel.locator('body')).toHaveAttribute('data-extraction', 'complete') + const alternate = await page.context().newPage() + try { + await alternate.goto(`${pageOrigin}/alternate`) + await alternate.bringToFront() + await page.bringToFront() + } finally { + await alternate.close() + await panel.evaluate(() => document.dispatchEvent(new Event('release-extraction'))) + } + const reply = await pending + assert( + reply.type === 'send-context-error' && reply.message.includes('changed'), + 'Returning to the same tab released stale page content' + ) + await setReference(false) + } + ) + await check( + 'Stale capture intents cannot interrupt an explicit send-time reference', + async () => { + await page.goto(`${pageOrigin}/stale-intent-source`) + await page.bringToFront() + for (const stale of ['expired', 'previous-session', 'other-window', 'future'] as const) { + const intent = await pendingIntent() + const sourceWindowId = intent.windowId + if (stale === 'expired') intent.createdAt -= 31_000 + else if (stale === 'future') intent.createdAt += 60_000 + else if (stale === 'other-window') intent.windowId++ + else { + receiverSessionId = generateId() + await ready() + } + await setReference(true) + const before = (await snapshots()).length + const key = `captureIntent:${sourceWindowId}:${stale}` + await panel.evaluate((key) => { + const original = chrome.scripting.executeScript.bind(chrome.scripting) + document.body.dataset.extraction = 'pending' + delete document.body.dataset.staleIntent + const changed = (changes: Record, area: string) => { + if (area !== 'session' || !changes[key]?.newValue) return + chrome.storage.onChanged.removeListener(changed) + document.body.dataset.staleIntent = key + } + chrome.storage.onChanged.addListener(changed) + chrome.scripting.executeScript = (async ( + injection: chrome.scripting.ScriptInjection + ) => { + chrome.scripting.executeScript = original + const result = await original(injection) + document.body.dataset.extraction = 'complete' + await new Promise((resolve) => + document.addEventListener('release-extraction', () => resolve(), { once: true }) + ) + return result + }) as typeof chrome.scripting.executeScript + }, key) + const pending = reference() + try { + await expect(panel.locator('body')).toHaveAttribute('data-extraction', 'complete') + await worker.evaluate( + ({ key, intent }) => chrome.storage.session.set({ [key]: intent }), + { + key, + intent, + } + ) + await expect(panel.locator('body')).toHaveAttribute('data-stale-intent', key) + } finally { + await panel.evaluate(() => document.dispatchEvent(new Event('release-extraction'))) + } + const reply = await pending + assert( + reply.type === 'send-context' && reply.snapshot.text.includes('visible-reference-482'), + `${stale} intent cancelled the current reference: ${reply.type}` + ) + const replayKey = `${key}:idle-replay` + const keys = [key, replayKey] + // A fresh storage event drains deferred intents without depending on the periodic sweep's phase. + await worker.evaluate(({ key, intent }) => chrome.storage.session.set({ [key]: intent }), { + key: replayKey, + intent, + }) + await expect + .poll( + () => + worker.evaluate(async (keys) => { + const stored = await chrome.storage.session.get(keys) + return keys.every((key) => !(key in stored)) + }, keys), + { message: `${stale}: idle storage delivery drains deferred stale intents` } + ) + .toBe(true) + await settleMessages() + assert((await snapshots()).length === before, `${stale} intent attached a manual page`) + await setReference(false) + } + } + ) + await check( + 'Site access requests only the address the user confirms, without reading the page', + async () => { + const source = 'https://private-source.example' + const privateUrl = `${source}/sensitive-path?token=PRIVATE_URL_SENTINEL#fragment` + await page + .context() + .route(`${source}/**`, (route) => + route.fulfill({ contentType: 'text/html', body: '

PRIVATE_CONTENT_SENTINEL

' }) + ) + await page.goto(privateUrl) + await page.bringToFront() + await setReference(true) + const before = (await snapshots()).length + const denied = await reference() + assert(denied.type === 'send-context-error', 'Reference read the private site without access') + assert( + !denied.message.includes('private-source') && !denied.message.includes('PRIVATE'), + 'Access failure exposed the private URL' + ) + await panel.evaluate(() => { + const requests: chrome.permissions.Permissions[] = [] + const request = chrome.permissions.request.bind(chrome.permissions) + chrome.permissions.request = ((permissions: chrome.permissions.Permissions) => { + requests.push(permissions) + document.body.dataset.requestedAccess = JSON.stringify(requests) + document.body.dataset.requestGesture = String(navigator.userActivation.isActive) + return request(permissions) + }) as typeof chrome.permissions.request + const execute = chrome.scripting.executeScript.bind(chrome.scripting) + chrome.scripting.executeScript = ((...args: Parameters) => { + document.body.dataset.unrequestedRead = 'true' + return execute(...args) + }) as typeof chrome.scripting.executeScript + }) + await panel.getByRole('button', { name: 'Request site access' }).click() + const dialog = panel.getByRole('dialog', { name: 'Site access' }) + await expect(dialog).toBeVisible() + const address = dialog.getByRole('textbox', { name: 'Site address' }) + await expect(address).toHaveValue('') + const allow = dialog.getByRole('button', { name: /^Allow access/ }) + for (const unsafe of [ + 'https://user:password@example.com/', + 'https://*.example.com/', + 'https://%2A.example.com/', + 'https://*.example.com/', + '', + 'chrome://settings/', + ]) { + await address.fill(unsafe) + await expect(allow, `Do not offer to grant access for ${unsafe}`).toHaveAccessibleName( + 'Allow access' + ) + await expect(allow).toBeDisabled() + } + await address.fill('http://intranet.example:8080/private-path') + await expect(allow).toHaveAccessibleName('Allow access to http://intranet.example:8080') + const allowedHost = 'https://approved-site.example' + await address.fill(`${allowedHost}/private-path?token=INPUT_SECRET#fragment`) + await expect(allow).toHaveAccessibleName(`Allow access to ${allowedHost}`) + assert( + (await panel.locator('body').getAttribute('data-requested-access')) === null, + 'Typing initiated a permission request' + ) + const grantedSites = () => + worker.evaluate(async () => ((await chrome.permissions.getAll()).origins ?? []).sort()) + const previousAccess = await grantedSites() + await allow.click() + await expect(panel.locator('body')).toHaveAttribute('data-request-gesture', 'true') + const requested = await panel.locator('body').getAttribute('data-requested-access') + assert(requested, 'The user action never reached Chrome') + expect(JSON.parse(requested)).toEqual([{ origins: ['https://approved-site.example:443/*'] }]) + expect(await grantedSites(), 'A site received persistent access without approval').toEqual( + previousAccess + ) + await settleMessages() + assert( + (await panel.locator('body').getAttribute('data-unrequested-read')) === null, + 'Managing site access attempted to read page content' + ) + assert((await snapshots()).length === before, 'Managing site access attached a page') + await expect(app.locator('body')).toHaveAttribute('data-reference-request', denied.requestId) + } + ) + await check( + 'Opted-in shortcut recovery redirects only explicit searches on the chosen providers', + async () => { + const tab = await page.context().newPage() + const toggle = async (provider: string, enabled: boolean) => { + const result = await panel.evaluate( + ({ provider, enabled }) => + chrome.runtime.sendMessage({ type: 'set-search-recovery', provider, enabled }), + { provider, enabled } + ) + assert( + result?.ok === true, + `Could not ${enabled ? 'enable' : 'disable'} ${provider} recovery` + ) + } + const unchanged = async (url: string) => { + await tab.goto(url) + assert(tab.url() === url, `An ordinary or ambiguous search was redirected: ${url}`) + assert( + (await tab.title()) === 'Local search fixture', + 'A provider request escaped the local fixture' + ) + } + try { + for (const provider of searchProviders) { + const endpoint = `https://${provider.hosts[0]}${provider.path}` + const url = `${endpoint}?${provider.parameter}=sim%2Faskit` + await unchanged(url) + await toggle(provider.id, true) + for (const host of provider.hosts) { + for (const shortcut of ['sim%2Faskit', 'sim/jira/ABC-123']) { + const candidate = `https://${host}${provider.path}?client=chrome&${provider.parameter}=${shortcut}&source=qa` + const prior = searchRequests.filter((request) => request.url === candidate).length + await tab.goto(candidate) + assert( + tab.url() === `${origin}/sim-links/recover?q=${shortcut}`, + `${provider.id} lost the explicit shortcut` + ) + assert( + searchRequests.filter((request) => request.url === candidate).length === prior, + 'The matched shortcut reached the search provider' + ) + } + } + for (const query of [ + `${provider.parameter}=quarterly+planning`, + `${provider.parameter}=sim%2Faskit%20help`, + `${provider.parameter}=sim%2Faskit+help`, + `${provider.parameter}=sim%252Faskit`, + `${provider.parameter}=sim%2Faskit%ZZ`, + `${provider.parameter}=sim%2Faskit&${provider.parameter}=other`, + `${provider.parameter}=other&${provider.parameter}=sim%2Faskit`, + `${provider.parameter}=sim%2Faskit&%${provider.parameter.charCodeAt(0).toString(16)}=other`, + ]) + await unchanged(`${endpoint}?${query}`) + await toggle(provider.id, false) + await unchanged(url) + assert( + await worker.evaluate( + (origins) => chrome.permissions.contains({ origins }), + provider.hosts.map((host) => `https://${host}/*`) + ), + 'Disabling recovery removed a capture permission' + ) + } + await toggle('google', true) + for (const url of [ + 'https://www.google.com/other?q=sim%2Faskit', + 'https://m.google.com/search?q=sim%2Faskit', + 'https://www.google.com.evil.test/search?q=sim%2Faskit', + 'https://www.google.com/search?%71=sim%2Faskit', + ]) + await unchanged(url) + const search = 'https://www.google.com/search?q=sim%2Faskit' + await tab.goto(`${origin}/post-fixture`) + await Promise.all([ + tab.waitForNavigation(), + tab.evaluate((url) => { + const form = document.createElement('form') + form.method = 'POST' + form.action = url + document.body.append(form) + form.submit() + }, search), + ]) + assert( + tab.url() === search && + searchRequests.some((request) => request.url === search && request.method === 'POST'), + 'A POST search was recovered' + ) + await tab.goto(`${origin}/frame-fixture`) + await Promise.all([ + tab.waitForEvent( + 'framenavigated', + (child) => child.parentFrame() !== null && child.url() === search + ), + tab.evaluate((url) => { + const child = document.createElement('iframe') + child.src = url + document.body.append(child) + }, search), + ]) + assert( + tab.url() === `${origin}/frame-fixture`, + 'A subframe search changed the top-level page' + ) + await toggle('google', false) + } finally { + await tab.close() + } + } + ) await mkdir(dirname(output), { recursive: true }) await panel.screenshot({ path: output.replace(/\.json$/, '.png'), fullPage: true }) } catch (error) { @@ -837,6 +1530,7 @@ try { } finally { await context?.close() fixture.stop(true) + searchFixture?.stop(true) await mkdir(dirname(output), { recursive: true }) await writeFile(output, JSON.stringify({ suite: 'browser-extension', checks }, null, 2)) await rm(temporary, { recursive: true, force: true }) diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index c6585bc53dc..b0a76732c7f 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -1,15 +1,25 @@ +import { SIM_LINK_DRAFT_PATH, simLinkDraftDestination } from '@sim/browser-extension-protocol' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { CAPTURE_INTENT_KEY, captureIntentKey } from '@/config' +import { + CAPTURE_INTENT_KEY, + captureIntentKey, + OPEN_SIM_COMMAND, + REFERENCE_PAGE_KEY, + SIM_ORIGIN, +} from '@/config' import { captureIntentSchema } from '@/intent' import { registerSimLinkOmnibox } from '@/omnibox' +import { registerSearchRecovery } from '@/search-recovery' import { digestUrl } from '@/utils' const logger = createLogger('BrowserExtension') const SELECTION_MENU = 'ask-sim-selection' +const LINK_MENU = 'create-sim-link' async function configure() { await chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }) + await chrome.storage.local.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }) await chrome.contextMenus.removeAll() chrome.contextMenus.create({ id: SELECTION_MENU, @@ -17,9 +27,16 @@ async function configure() { contexts: ['selection'], documentUrlPatterns: ['http://*/*', 'https://*/*'], }) + chrome.contextMenus.create({ + id: LINK_MENU, + title: 'Create Sim Link', + contexts: ['link'], + documentUrlPatterns: ['http://*/*', 'https://*/*'], + targetUrlPatterns: ['http://*/*', 'https://*/*'], + }) } -function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') { +function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') { if (tab.incognito) return // Chrome requires open() in the original user-gesture task, before any await. const opened = chrome.sidePanel.open({ windowId: tab.windowId }) @@ -31,17 +48,39 @@ function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') { const identity = chrome.scripting.executeScript({ target: { tabId: tab.id, frameIds: [0] }, world: 'ISOLATED', - func: () => location.href, + func: (keyboard: boolean) => { + if (!keyboard) return { href: location.href, selected: false } + const active = document.activeElement + const protectedFocus = + active instanceof HTMLElement && + (active.isContentEditable || + active.matches('iframe,frame,input,textarea,select') || + Boolean(chrome.dom.openOrClosedShadowRoot(active)?.activeElement)) + return { + href: location.href, + selected: protectedFocus || window.getSelection()?.isCollapsed === false, + } + }, + args: [trigger === 'keyboard'], }) - void Promise.all([opened, identity, digestUrl(tab.url)]) - .then(([, result, urlDigest]) => { + const referenceEnabled = + trigger !== 'selection' && + chrome.storage.local + .get(REFERENCE_PAGE_KEY) + .then((values) => values[REFERENCE_PAGE_KEY] === true) + .catch(() => false) + void Promise.all([opened, identity, digestUrl(tab.url), referenceEnabled]) + .then(([, result, urlDigest, enabled]) => { const document = result[0] - if (!document?.documentId || document.result !== tab.url) return + if (!document?.documentId || !document.result || document.result.href !== tab.url) return + const kind = trigger === 'selection' || document.result.selected ? 'selection' : 'page' + if (enabled && kind === 'page' && trigger !== 'keyboard') return return chrome.storage.session.set({ [captureIntentKey(tab.windowId, generateId())]: { tabId: tab.id, windowId: tab.windowId, - kind, + kind: enabled && kind === 'page' ? 'focus' : kind, + focusComposer: trigger === 'keyboard', createdAt, documentId: document.documentId, urlDigest, @@ -52,15 +91,27 @@ function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') { } registerSimLinkOmnibox() +registerSearchRecovery() chrome.runtime.onInstalled.addListener(() => { void configure().catch(() => logger.warn('Could not initialize extension controls')) }) chrome.action.onClicked.addListener((tab) => open(tab, 'page')) chrome.contextMenus.onClicked.addListener((info, tab) => { - if (info.menuItemId === SELECTION_MENU && tab && !info.editable) open(tab, 'selection') + if (!tab || tab.incognito || info.editable) return + if (info.menuItemId === LINK_MENU && info.linkUrl) { + const destination = simLinkDraftDestination(info.linkUrl) + if (destination) { + const draft = new URL(SIM_LINK_DRAFT_PATH, SIM_ORIGIN) + draft.hash = new URLSearchParams({ url: destination }).toString() + void chrome.tabs + .create({ url: draft.href, windowId: tab.windowId }) + .catch(() => logger.warn('Could not open the Sim Link draft')) + } + } + if (info.menuItemId === SELECTION_MENU) open(tab, 'selection') }) chrome.commands.onCommand.addListener((command, tab) => { - if (command === 'open-sim' && tab) open(tab, 'page') + if (command === OPEN_SIM_COMMAND && tab) open(tab, 'keyboard') }) function clearTabIntent(tabId: number) { void chrome.storage.session diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index dbd5d253f50..984874204a9 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -8,25 +8,98 @@ import { SIM_ORIGIN } from '@/config' import { extractPageText } from '@/extract' import { digestUrl } from '@/utils' +/** A public capture error whose message is safe to share with the hosted composer. */ +export class PageCaptureError extends Error {} + +/** Chrome scripting failures may contain the full private URL of the tab. */ +export function captureErrorMessage(error: unknown): string { + return error instanceof PageCaptureError + ? error.message + : 'This page could not be read. Check site access and try again.' +} + +/** Only emitted after verifying the selected document is still active. */ +export class UnreadableSelectionError extends PageCaptureError { + constructor( + message: string, + readonly tabId: number, + readonly windowId: number + ) { + super(message) + } +} + +export class SiteAccessRequiredError extends PageCaptureError { + constructor( + readonly tabId: number, + readonly windowId: number + ) { + super('Allow site access to attach this page.') + } +} + /** Captures only the focused tab and rejects navigation or tab changes during extraction. */ export async function captureActivePage( kind: 'page' | 'selection', - expected?: { tabId: number; documentId: string; urlDigest: string } + expected?: { tabId: number; documentId: string; urlDigest: string }, + signal?: AbortSignal ) { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) - if (tab?.id === undefined || !tab.url || (expected !== undefined && expected.tabId !== tab.id)) { - throw new Error('Activate the page and click the Sim toolbar icon to allow access.') + signal?.throwIfAborted() + if (tab?.id === undefined || tab.incognito) + throw new PageCaptureError('This page cannot be attached.') + if (expected && expected.tabId !== tab.id) { + throw new PageCaptureError('The page changed before reading. Attach it again.') + } + if (!tab.url) throw new SiteAccessRequiredError(tab.id, tab.windowId) + let changed = false + const activated = (active: chrome.tabs.OnActivatedInfo) => { + if (active.windowId === tab.windowId && active.tabId !== tab.id) changed = true } + const updated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { + if (tabId === tab.id && (change.status === 'loading' || change.url !== undefined)) + changed = true + } + chrome.tabs.onActivated.addListener(activated) + chrome.tabs.onUpdated.addListener(updated) + const removeListeners = () => { + chrome.tabs.onActivated.removeListener(activated) + chrome.tabs.onUpdated.removeListener(updated) + signal?.removeEventListener('abort', removeListeners) + } + signal?.addEventListener('abort', removeListeners, { once: true }) + try { + const snapshot = await readTab({ tabId: tab.id, url: tab.url }, kind, expected, signal) + if (changed) throw new PageCaptureError('The page changed while reading. Attach it again.') + return snapshot + } catch (error) { + if (changed) throw new PageCaptureError('The page changed while reading. Attach it again.') + if (expected && !(error instanceof PageCaptureError)) + throw new PageCaptureError( + 'The original document cannot be read. Attach the current page again.' + ) + throw error + } finally { + removeListeners() + } +} + +async function readTab( + tab: { tabId: number; url: string }, + kind: 'page' | 'selection', + expected?: { documentId: string; urlDigest: string }, + signal?: AbortSignal +) { const url = new URL(tab.url) if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) { - throw new Error('The page changed before reading. Attach it again.') + throw new PageCaptureError('The page changed before reading. Attach it again.') } let pathname = url.pathname if (url.origin === SIM_ORIGIN) { try { pathname = decodeURIComponent(pathname) } catch { - throw new Error('This page cannot be attached.') + throw new PageCaptureError('This page cannot be attached.') } } if ( @@ -34,32 +107,39 @@ export async function captureActivePage( (url.origin === SIM_ORIGIN && (pathname === '/extension' || pathname.startsWith('/extension/'))) || url.username || - url.password || - tab.incognito + url.password ) { - throw new Error('This page cannot be attached.') + throw new PageCaptureError('This page cannot be attached.') } const results = await chrome.scripting.executeScript({ target: expected - ? { tabId: tab.id, documentIds: [expected.documentId] } - : { tabId: tab.id, frameIds: [0] }, + ? { tabId: tab.tabId, documentIds: [expected.documentId] } + : { tabId: tab.tabId, frameIds: [0] }, world: 'ISOLATED', func: extractPageText, args: [kind, PAGE_SNAPSHOT_MAX_TEXT_LENGTH, PAGE_SNAPSHOT_MAX_BYTES], }) const result = results[0] - if (!result?.documentId || !result.result) throw new Error('This page could not be read.') - const [current] = await chrome.tabs.query({ active: true, currentWindow: true }) - if (current?.id !== tab.id || current.url !== tab.url || current.status === 'loading') { - throw new Error('The page changed while reading. Attach it again.') - } + signal?.throwIfAborted() + if (!result?.documentId || !result.result) + throw new PageCaptureError('This page could not be read.') const identity = await chrome.scripting.executeScript({ - target: { tabId: tab.id, documentIds: [result.documentId] }, + target: { tabId: tab.tabId, documentIds: [result.documentId] }, func: () => location.href, world: 'ISOLATED', }) if (identity[0]?.documentId !== result.documentId || identity[0]?.result !== tab.url) { - throw new Error('The page changed while reading. Attach it again.') + throw new PageCaptureError('The page changed while reading. Attach it again.') + } + const [current] = await chrome.tabs.query({ active: true, currentWindow: true }) + signal?.throwIfAborted() + if (current?.id !== tab.tabId || current.url !== tab.url || current.status === 'loading') { + throw new PageCaptureError('The page changed while reading. Attach it again.') + } + if (result.result.error) { + if (kind === 'selection') + throw new UnreadableSelectionError(result.result.error, current.id, current.windowId) + throw new PageCaptureError(result.result.error) } return pageSnapshotSchema.parse({ ...result.result, id: generateId() }) } diff --git a/apps/extension/src/config.ts b/apps/extension/src/config.ts index b96cb152715..b6d38c9745b 100644 --- a/apps/extension/src/config.ts +++ b/apps/extension/src/config.ts @@ -1,5 +1,7 @@ declare const __SIM_ORIGIN__: string export const SIM_ORIGIN = __SIM_ORIGIN__ +export const REFERENCE_PAGE_KEY = 'referenceCurrentPage' +export const OPEN_SIM_COMMAND = 'open-sim' export const CAPTURE_INTENT_KEY = 'captureIntent' export const CAPTURE_INTENT_TTL_MS = 30_000 export const captureIntentKey = (windowId: number, intentId: string) => diff --git a/apps/extension/src/extract.ts b/apps/extension/src/extract.ts index 5b04198d251..b96882f64f2 100644 --- a/apps/extension/src/extract.ts +++ b/apps/extension/src/extract.ts @@ -7,19 +7,30 @@ export function extractPageText( const startedAt = performance.now() const source = new URL(location.href) if (!['http:', 'https:'].includes(source.protocol) || source.username || source.password) { - throw new Error('This page cannot be attached.') + return { error: 'This page cannot be attached.' } } source.search = '' source.hash = '' if (kind === 'selection' && document.activeElement?.matches('iframe,frame')) - throw new Error('Open the embedded page in its own tab to attach selected text.') + return { + error: 'The selection could not be read from an embedded frame. Open it in its own tab.', + } + const active = document.activeElement + if ( + kind === 'selection' && + active instanceof HTMLElement && + (active.isContentEditable || + active.matches('input,textarea,select') || + chrome.dom.openOrClosedShadowRoot(active)?.activeElement) + ) + return { error: 'Select text outside an editable or embedded field.' } const selection = kind === 'selection' ? window.getSelection() : null if (kind === 'selection' && (!selection || selection.isCollapsed || selection.rangeCount !== 1)) { - throw new Error('Select text on the page, then choose Ask Sim from the context menu.') + return { error: 'Select text on the page, then attach the selection.' } } const range = selection?.getRangeAt(0) const root = range?.commonAncestorContainer ?? document.body - if (!root) throw new Error('This page has no readable text.') + if (!root) return { error: 'This page has no readable text.' } const excluded = 'script,style,noscript,template,iframe,object,embed,canvas,svg,input,textarea,select,button,[contenteditable]:not([contenteditable="false"]),[hidden],[aria-hidden="true"],[data-sim-extension-ignore]' @@ -37,12 +48,11 @@ export function extractPageText( let ancestor = root.nodeType === Node.ELEMENT_NODE ? (root as Element) : root.parentElement while (ancestor) { if (performance.now() - startedAt > 150) - throw new Error('Reading this page took too long. Select a smaller section.') + return { error: 'The selection could not be read in time. Select a smaller section.' } if (++visited > 15_000) - throw new Error( - 'This selection is too deeply nested to capture safely. Select a smaller section.' - ) - if (hidden(ancestor)) throw new Error('This selection is inside an editable or hidden field.') + return { error: 'The selection could not be read safely. Select a smaller section.' } + if (hidden(ancestor)) + return { error: 'The selection could not be read from an editable or hidden field.' } ancestor = ancestor.parentElement } @@ -101,7 +111,7 @@ export function extractPageText( } const text = parts.join('\n') if (!text) - throw new Error('No readable text was found. This page may use a document viewer or canvas.') + return { error: 'No readable text was found. This page may use a document viewer or canvas.' } return { url: source.href, title: document.title.slice(0, 512), diff --git a/apps/extension/src/intent.ts b/apps/extension/src/intent.ts index a3732988ad8..0a91b12070f 100644 --- a/apps/extension/src/intent.ts +++ b/apps/extension/src/intent.ts @@ -3,8 +3,11 @@ import { z } from 'zod' export const captureIntentSchema = z.strictObject({ tabId: z.number().int(), windowId: z.number().int(), - kind: z.enum(['page', 'selection']), + kind: z.enum(['page', 'selection', 'focus']), + focusComposer: z.boolean().optional(), createdAt: z.number(), documentId: z.string().min(1).max(128), urlDigest: z.string().regex(/^[a-f0-9]{64}$/), }) + +export type CaptureIntent = z.infer diff --git a/apps/extension/src/omnibox.ts b/apps/extension/src/omnibox.ts index af6a68cea80..008f9b9a3b7 100644 --- a/apps/extension/src/omnibox.ts +++ b/apps/extension/src/omnibox.ts @@ -12,6 +12,9 @@ const logger = createLogger('BrowserExtensionOmnibox') /** Longer than the gap between a typist's keystrokes, so a name is looked up once it settles. */ const SUGGEST_DELAY_MS = 220 +const SEARCH_HINT = 'Find a Sim link' +// Chrome persists this template and substitutes the input when rendering the default row. +const OPEN_LINK_HINT = 'Open %s in Sim' /** A Sim link as suggested: its name, and what it shows beside the name. */ interface SuggestedLink { @@ -117,14 +120,26 @@ export function registerSimLinkOmnibox() { */ const linksByName = new LRUCache({ max: 100 }) - chrome.omnibox.onInputStarted.addListener(() => linksByName.clear()) + let defaultHint: string | undefined + function showHint(hint: string) { + if (hint === defaultHint) return + defaultHint = hint + chrome.omnibox.setDefaultSuggestion({ description: hint }) + } + + function resetSession() { + pending?.abort() + linksByName.clear() + showHint(SEARCH_HINT) + } + + showHint(SEARCH_HINT) + chrome.omnibox.onInputStarted.addListener(resetSession) chrome.omnibox.onInputChanged.addListener((text, suggest) => { pending?.abort() const { name, rest } = readTyped(text) - chrome.omnibox.setDefaultSuggestion({ - description: `Open sim/${escapeXml(`${name}${rest}`)}`, - }) + showHint(name || rest ? OPEN_LINK_HINT : SEARCH_HINT) // A name can't hold a space, so `sim card prices` is a search, not a link: ask Sim nothing. if (/\s/.test(name) || name.length > SIM_LINK_NAME_MAX_LENGTH) { suggest([]) @@ -144,17 +159,18 @@ export function registerSimLinkOmnibox() { // Signed out or rate limited: the typed link still opens through the resolver, and the name // is asked about again on the next keystroke rather than remembered as having no links. const links = await fetchSuggestedLinks(key, controller.signal) + if (controller.signal.aborted) return if (links) linksByName.set(key, links) - if (!controller.signal.aborted) suggest(toSuggestions(links ?? [], rest)) + suggest(toSuggestions(links ?? [], rest)) })().catch(() => { if (!controller.signal.aborted) logger.warn('Could not load Sim link suggestions') }) }) - chrome.omnibox.onInputCancelled.addListener(() => pending?.abort()) + chrome.omnibox.onInputCancelled.addListener(resetSession) chrome.omnibox.onInputEntered.addListener((text, disposition) => { - pending?.abort() + resetSession() const url = simLinkUrl(text) const opened = disposition === 'currentTab' diff --git a/apps/extension/src/reference.ts b/apps/extension/src/reference.ts new file mode 100644 index 00000000000..eafd28b586d --- /dev/null +++ b/apps/extension/src/reference.ts @@ -0,0 +1,185 @@ +import { + type ExtensionToAppMessage, + PAGE_REFERENCE_TIMEOUT_MS, +} from '@sim/browser-extension-protocol' +import { LRUCache } from 'lru-cache' +import { + captureActivePage, + captureErrorMessage, + PageCaptureError, + SiteAccessRequiredError, +} from '@/capture' +import { REFERENCE_PAGE_KEY } from '@/config' + +interface ReferenceOptions { + session: () => string | null + post: (message: ExtensionToAppMessage) => void + manualCaptureError: () => { message: string; reason?: 'selection' } | undefined + showStatus: (message: string | SiteAccessRequiredError) => void + clearAccessStatus: () => void +} + +/** Correlates an explicit Send with a single active-page read in the current conversation. */ +export function createReferenceCapture({ + session, + post, + manualCaptureError, + showStatus, + clearAccessStatus, +}: ReferenceOptions) { + const envelope = { channel: 'sim-extension', version: 1 } as const + const requests = new LRUCache({ max: 64, ttl: PAGE_REFERENCE_TIMEOUT_MS }) + let pending: { sessionId: string; requestId: string; controller: AbortController } | null = null + let enabled = false + let revision = 0 + let preferenceWrite: Promise | null = null + + function replyError( + sessionId: string, + requestId: string, + error: string | SiteAccessRequiredError, + reason?: 'selection' + ) { + if (session() !== sessionId) return + const message = typeof error === 'string' ? error : error.message + if (typeof error !== 'string' || reason) showStatus(error) + post({ + ...envelope, + type: 'send-context-error', + sessionId, + requestId, + message, + ...(typeof error !== 'string' ? { reason: 'site-access' as const } : reason && { reason }), + }) + } + + function cancel(message?: string) { + if (!pending) return + pending.controller.abort() + if (message) replyError(pending.sessionId, pending.requestId, message) + pending = null + } + + function publishPreference(next: boolean) { + enabled = next + if (!next) cancel('Reference current page was turned off. Send again to continue.') + const sessionId = session() + if (sessionId) post({ ...envelope, type: 'reference-preference', sessionId, enabled }) + } + + async function refreshPreference(sessionId = session()): Promise { + while (sessionId && session() === sessionId) { + const current = revision + await preferenceWrite + try { + const values = await chrome.storage.local.get(REFERENCE_PAGE_KEY) + if (session() !== sessionId) return false + if (current !== revision || preferenceWrite) continue + publishPreference(values[REFERENCE_PAGE_KEY] === true) + return true + } catch { + if (session() !== sessionId) return false + if (current !== revision || preferenceWrite) continue + publishPreference(false) + showStatus( + 'Page reference is unavailable. Attach the page manually or reopen Sim to try again.' + ) + return true + } + } + return false + } + + async function setPreference(next: boolean) { + if (!navigator.userActivation.isActive) { + publishPreference(enabled) + return + } + // A disable takes effect before the storage change event, including for a read in progress. + revision++ + if (!next) { + clearAccessStatus() + publishPreference(false) + } + const write = chrome.storage.local.set({ [REFERENCE_PAGE_KEY]: next }).catch(() => { + showStatus('Could not save the page reference setting.') + }) + preferenceWrite = write + await write + if (preferenceWrite === write) { + preferenceWrite = null + await refreshPreference() + } + } + + async function capture(sessionId: string, requestId: string) { + if (requests.has(requestId)) return + requests.set(requestId, true) + if (!navigator.userActivation.isActive || !enabled) { + replyError(sessionId, requestId, 'Send again with Reference current page enabled.') + return + } + const manualError = manualCaptureError() + if (pending || manualError) { + replyError( + sessionId, + requestId, + manualError?.message ?? + 'A page attachment is still being prepared. Try again once it appears.', + manualError?.reason + ) + return + } + const request = { sessionId, requestId, controller: new AbortController() } + pending = request + const started = performance.now() + const expired = 'The page reference expired. Send again to continue.' + const timeout = window.setTimeout(() => { + if (pending === request) cancel(expired) + }, PAGE_REFERENCE_TIMEOUT_MS) + const assertCurrent = () => { + if ( + pending !== request || + session() !== sessionId || + !enabled || + performance.now() - started >= PAGE_REFERENCE_TIMEOUT_MS + ) + throw new PageCaptureError(expired) + } + try { + const snapshot = await captureActivePage('page', undefined, request.controller.signal) + assertCurrent() + clearAccessStatus() + post({ ...envelope, type: 'send-context', sessionId, requestId, snapshot }) + } catch (error) { + if (pending !== request || session() !== sessionId) return + replyError( + sessionId, + requestId, + error instanceof SiteAccessRequiredError ? error : captureErrorMessage(error) + ) + } finally { + window.clearTimeout(timeout) + if (pending === request) pending = null + } + } + + return { + capture, + refreshPreference, + setPreference, + busy: () => pending !== null, + manualRequested: () => + cancel('An explicit attachment was requested. Try again after it appears.'), + preferenceChanged(value: unknown) { + if (preferenceWrite) return + revision++ + publishPreference(value === true) + }, + reset() { + revision++ + cancel() + requests.clear() + }, + } +} diff --git a/apps/extension/src/search-recovery-dialog.tsx b/apps/extension/src/search-recovery-dialog.tsx new file mode 100644 index 00000000000..bc89f7e943b --- /dev/null +++ b/apps/extension/src/search-recovery-dialog.tsx @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + Chip, + ChipModalBody, + ChipModalDescription, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + ChipModalSurface, + Switch, +} from '@sim/emcn' +import { + readSearchRecovery, + SEARCH_RECOVERY_KEY, + SEARCH_RECOVERY_PROVIDERS, + setSearchRecovery, +} from '@/search-recovery' + +interface SearchRecoveryDialogProps { + onClose: () => void +} + +type SearchProvider = (typeof SEARCH_RECOVERY_PROVIDERS)[number] +type SearchRecovery = Awaited> + +/** Chrome owns modality so the dialog works without an injected scroll-lock stylesheet. */ +function mountSearchRecoveryDialog(dialog: HTMLDialogElement | null) { + if (!dialog) return + dialog.showModal() + return () => dialog.close() +} + +export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { + const generation = useRef(0) + const [settings, setSettings] = useState() + const [busyProvider, setBusyProvider] = useState(null) + const [notices, setNotices] = useState>>({}) + const [loadError, setLoadError] = useState(false) + + const refresh = useCallback(async () => { + const current = ++generation.current + try { + const next = await readSearchRecovery() + if (current === generation.current) { + setSettings(next) + setLoadError(false) + } + } catch { + if (current === generation.current) setLoadError(true) + } + }, []) + + useEffect(() => { + const update = () => void refresh() + const stored = (changes: Record, area: string) => { + if (area === 'local' && SEARCH_RECOVERY_KEY in changes) update() + } + update() + chrome.permissions.onAdded.addListener(update) + chrome.permissions.onRemoved.addListener(update) + chrome.storage.onChanged.addListener(stored) + return () => { + generation.current++ + chrome.permissions.onAdded.removeListener(update) + chrome.permissions.onRemoved.removeListener(update) + chrome.storage.onChanged.removeListener(stored) + } + }, [refresh]) + + async function changeProvider(provider: SearchProvider, enabled: boolean) { + if (!settings || busyProvider || loadError) return + setBusyProvider(provider.id) + setNotices((previous) => ({ ...previous, [provider.id]: '' })) + try { + if (enabled && !(await chrome.permissions.request({ origins: [...provider.origins] }))) { + setNotices((previous) => ({ + ...previous, + [provider.id]: 'Site access was not granted. This shortcut is still off.', + })) + return + } + await setSearchRecovery(provider.id, enabled) + await refresh() + } catch { + setNotices((previous) => ({ + ...previous, + [provider.id]: 'Could not change this shortcut. Try again.', + })) + await refresh() + } finally { + setBusyProvider(null) + } + } + + return ( + { + event.preventDefault() + onClose() + }} + onClick={(event) => { + if (event.target === event.currentTarget) onClose() + }} + > + + + Browser shortcuts + + + + Send explicit sim/ searches to your Sim Links. Ordinary searches stay with your search + engine. + + {SEARCH_RECOVERY_PROVIDERS.map((provider) => ( + void changeProvider(provider, enabled)} + /> + } + hint={provider.origins.map((origin) => new URL(origin).origin).join(', ')} + submitOnEnter={false} + > + {notices[provider.id] && ( +

+ {notices[provider.id]} +

+ )} +
+ ))} +

+ Chrome asks for access to the sites shown above. Page reference stays unchanged. Turning + a shortcut off keeps site access. +

+ {loadError && ( +
+

+ Could not load browser shortcuts. +

+ void refresh()}>Retry +
+ )} +
+ +
+
+ ) +} diff --git a/apps/extension/src/search-recovery.ts b/apps/extension/src/search-recovery.ts new file mode 100644 index 00000000000..f89356ad0a1 --- /dev/null +++ b/apps/extension/src/search-recovery.ts @@ -0,0 +1,198 @@ +import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' +import { escapeRegExp } from '@sim/utils/string' +import { SIM_ORIGIN } from '@/config' + +export const SEARCH_RECOVERY_KEY = 'searchRecovery' +export const SEARCH_RECOVERY_PROVIDERS = [ + { + id: 'google', + label: 'Google', + origins: ['https://www.google.com/*', 'https://google.com/*'], + path: '/search', + parameter: 'q', + ruleId: 101, + }, + { + id: 'bing', + label: 'Bing', + origins: ['https://www.bing.com/*', 'https://bing.com/*'], + path: '/search', + parameter: 'q', + ruleId: 102, + }, + { + id: 'duckduckgo', + label: 'DuckDuckGo', + origins: ['https://duckduckgo.com/*', 'https://www.duckduckgo.com/*'], + path: '/', + parameter: 'q', + ruleId: 103, + }, + { + id: 'yahoo', + label: 'Yahoo', + origins: ['https://search.yahoo.com/*'], + path: '/search', + parameter: 'p', + ruleId: 104, + }, +] as const + +type ProviderId = (typeof SEARCH_RECOVERY_PROVIDERS)[number]['id'] +type RecoveryPreferences = Record +const logger = createLogger('BrowserShortcutRecovery') +const MESSAGE_TYPE = 'set-search-recovery' + +/** Search text is never observed in JavaScript; Chrome matches only explicit shortcut requests. */ +function recoveryRules( + provider: (typeof SEARCH_RECOVERY_PROVIDERS)[number] +): chrome.declarativeNetRequest.Rule[] { + const hosts = provider.origins.map((origin) => escapeRegExp(new URL(origin).host)).join('|') + const character = '(?:[a-z0-9._~:@/-]|%(?:2[6def]|3[0-9a]|4[0-9a-f]|5[0-9af]|6[1-9a-f]|7[0-9ae]))' + const shortcut = `sim(?:/|%2f)${character}+` + const condition = { + isUrlFilterCaseSensitive: false, + requestDomains: provider.origins.map((origin) => new URL(origin).hostname), + resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], + requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET], + } + // Separate ambiguity guards keep the redirect below Chrome's compiled-regex memory limit. + return [ + { + id: provider.ruleId + 100, + priority: 2, + action: { type: chrome.declarativeNetRequest.RuleActionType.ALLOW }, + condition: { + ...condition, + regexFilter: `[?&]${provider.parameter}=.*&${provider.parameter}=`, + }, + }, + { + id: provider.ruleId + 200, + priority: 2, + action: { type: chrome.declarativeNetRequest.RuleActionType.ALLOW }, + condition: { ...condition, regexFilter: '[?&][^=&]*%[^=&]*=' }, + }, + { + id: provider.ruleId, + priority: 1, + action: { + type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, + redirect: { regexSubstitution: `${SIM_ORIGIN}/sim-links/recover?q=\\1` }, + }, + condition: { + ...condition, + regexFilter: `^https://(?:${hosts})${escapeRegExp(provider.path)}\\?(?:[^#]*&)?${provider.parameter}=(${shortcut})(?:&[^#]*)?$`, + }, + }, + ] +} + +/** A remembered choice is effective only while Chrome still grants that provider's sites. */ +export async function readSearchRecovery(): Promise { + const stored = (await chrome.storage.local.get(SEARCH_RECOVERY_KEY))[SEARCH_RECOVERY_KEY] + const preferences: RecoveryPreferences = { + google: false, + bing: false, + duckduckgo: false, + yahoo: false, + } + for (const provider of SEARCH_RECOVERY_PROVIDERS) { + preferences[provider.id] = + isRecordLike(stored) && + stored[provider.id] === true && + (await chrome.permissions.contains({ origins: [...provider.origins] })) + } + return preferences +} + +/** The worker serializes settings and rule updates across every open side panel. */ +export async function setSearchRecovery(provider: ProviderId, enabled: boolean): Promise { + const response: unknown = await chrome.runtime.sendMessage({ + type: MESSAGE_TYPE, + provider, + enabled, + }) + if (!isRecordLike(response) || response.ok !== true) + throw new Error('Could not update browser shortcuts.') +} + +/** Reconcile persisted, permission-bound rules at startup, on revocation, and after an explicit choice. */ +export function registerSearchRecovery() { + let pending = Promise.resolve() + const update = (choice?: { provider: ProviderId; enabled: boolean }) => { + const operation = pending.then(async () => { + const preferences = await readSearchRecovery() + if (choice) { + const provider = SEARCH_RECOVERY_PROVIDERS.find( + (provider) => provider.id === choice.provider + ) + if (!provider) throw new Error('Unknown shortcut provider.') + if ( + choice.enabled && + !(await chrome.permissions.contains({ origins: [...provider.origins] })) + ) + throw new Error('Search site access has not been granted.') + preferences[choice.provider] = choice.enabled + } + const rules = SEARCH_RECOVERY_PROVIDERS.filter( + (provider) => preferences[provider.id] + ).flatMap(recoveryRules) + for (const rule of rules) { + const supported = await chrome.declarativeNetRequest.isRegexSupported({ + regex: rule.condition.regexFilter ?? '', + isCaseSensitive: false, + requireCapturing: true, + }) + if (!supported.isSupported) throw new Error('Chrome cannot apply shortcut recovery.') + } + const ruleIds = SEARCH_RECOVERY_PROVIDERS.flatMap((provider) => [ + provider.ruleId, + provider.ruleId + 100, + provider.ruleId + 200, + ]) + const previousRules = (await chrome.declarativeNetRequest.getDynamicRules()).filter((rule) => + ruleIds.includes(rule.id) + ) + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: ruleIds, + addRules: rules, + }) + try { + await chrome.storage.local.set({ [SEARCH_RECOVERY_KEY]: preferences }) + } catch (error) { + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: ruleIds, + addRules: previousRules, + }) + throw error + } + }) + pending = operation.catch(() => logger.warn('Could not update browser shortcut recovery')) + return operation + } + const reconcile = () => { + void update().catch(() => {}) + } + chrome.runtime.onInstalled.addListener(reconcile) + chrome.runtime.onStartup.addListener(reconcile) + chrome.permissions.onRemoved.addListener(reconcile) + chrome.runtime.onMessage.addListener((message: unknown, sender, respond) => { + if ( + sender.id !== chrome.runtime.id || + !sender.url?.startsWith(chrome.runtime.getURL('')) || + !isRecordLike(message) || + message.type !== MESSAGE_TYPE || + typeof message.enabled !== 'boolean' + ) + return + const provider = SEARCH_RECOVERY_PROVIDERS.find((provider) => provider.id === message.provider) + if (!provider) return + void update({ provider: provider.id, enabled: message.enabled }).then( + () => respond({ ok: true }), + () => respond({ ok: false }) + ) + return true + }) +} diff --git a/apps/extension/src/shortcut-hint.tsx b/apps/extension/src/shortcut-hint.tsx new file mode 100644 index 00000000000..543390bdb1c --- /dev/null +++ b/apps/extension/src/shortcut-hint.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' +import { OPEN_SIM_COMMAND } from '@/config' + +/** Reads the binding in this Chrome profile, including changes made in the shortcut settings. */ +export function ShortcutHint() { + const [shortcut, setShortcut] = useState(null) + useEffect(() => { + let revision = 0 + const read = () => { + const current = ++revision + void chrome.commands + .getAll() + .then((commands) => { + if (current === revision) + setShortcut(commands.find((entry) => entry.name === OPEN_SIM_COMMAND)?.shortcut ?? '') + }) + .catch(() => { + if (current === revision) setShortcut(null) + }) + } + read() + window.addEventListener('focus', read) + return () => { + revision++ + window.removeEventListener('focus', read) + } + }, []) + if (shortcut === null) return null + return ( +

+ {shortcut ? ( + <> + For one-time access, press {shortcut} on the page. + + ) : ( + + )} +

+ ) +} diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index 5e6114cecdd..dc74d3d56f8 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -4,43 +4,78 @@ import { type ExtensionToAppMessage, type PageSnapshot, } from '@sim/browser-extension-protocol' -import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn' -import { getErrorMessage } from '@sim/utils/errors' +import { Chip } from '@sim/emcn' import { createRoot } from 'react-dom/client' -import { captureActivePage } from '@/capture' -import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, SIM_ORIGIN } from '@/config' -import { captureIntentSchema } from '@/intent' +import { + captureActivePage, + captureErrorMessage, + PageCaptureError, + SiteAccessRequiredError, + UnreadableSelectionError, +} from '@/capture' +import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIGIN } from '@/config' +import { type CaptureIntent, captureIntentSchema } from '@/intent' +import { createReferenceCapture } from '@/reference' +import { SearchRecoveryDialog } from '@/search-recovery-dialog' +import { ShortcutHint } from '@/shortcut-hint' +import { SiteAccessDialog } from '@/site-access' import '@/shell.css' +const BLOCKED_SELECTION = + 'Select readable text again, attach the page explicitly, or turn off Reference current page.' +const KEYBOARD_FOCUS_HINT = navigator.platform.startsWith('Mac') + ? 'To type here, click the message field or press ⌘⌥↑ to move focus.' + : 'To type here, click the message field or use F6 to move focus.' + interface SidePanelProps { origin: string } -/** Native modality avoids the inline scroll-lock stylesheet blocked by the extension CSP. */ -function mountAccessDialog(dialog: HTMLDialogElement | null) { - if (!dialog) return - dialog.showModal() - return () => dialog.close() -} - function SidePanel({ origin }: SidePanelProps) { const frameRef = useRef(null) const pendingRef = useRef(null) + const focusRequested = useRef(false) + const unreadableSelection = useRef<{ tabId: number; windowId: number } | null>(null) const sessionRef = useRef(null) + const connectedRef = useRef(false) const deliveredRef = useRef(null) const captureGenerationRef = useRef(0) const intentCutoffRef = useRef(0) const busyRef = useRef(false) const [accessOpen, setAccessOpen] = useState(false) - const [status, setStatus] = useState('') - const [site, setSite] = useState<{ origin: string; allowed: boolean } | null>(null) + const [shortcutsOpen, setShortcutsOpen] = useState(false) + const [status, setStatus] = useState('') + const referenceRef = useRef | null>(null) + + useEffect(() => { + if (!(status instanceof SiteAccessRequiredError)) return + const navigated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { + if (tabId === status.tabId && (change.status === 'loading' || change.url !== undefined)) { + setStatus((current) => + current === status ? 'The page changed. Try attaching it again.' : current + ) + } + } + const activated = (tab: chrome.tabs.OnActivatedInfo) => { + if (tab.windowId === status.windowId && tab.tabId !== status.tabId) + setStatus((current) => (current === status ? '' : current)) + } + chrome.tabs.onActivated.addListener(activated) + chrome.tabs.onUpdated.addListener(navigated) + return () => { + chrome.tabs.onActivated.removeListener(activated) + chrome.tabs.onUpdated.removeListener(navigated) + } + }, [status]) function deliver() { const snapshot = pendingRef.current const sessionId = sessionRef.current - if (!snapshot || !sessionId || deliveredRef.current === snapshot.id) return + if (!connectedRef.current || !snapshot || !sessionId || deliveredRef.current === snapshot.id) + return if (Date.now() - Date.parse(snapshot.capturedAt) > CAPTURE_INTENT_TTL_MS) { pendingRef.current = null + focusRequested.current = false setStatus('The capture expired. Attach the page again.') return } @@ -55,22 +90,52 @@ function SidePanel({ origin }: SidePanelProps) { deliveredRef.current = snapshot.id } + function deliverFocus() { + if (!focusRequested.current || !connectedRef.current || !sessionRef.current) return + focusRequested.current = false + if (document.querySelector('dialog[open]')) return + const message: ExtensionToAppMessage = { + channel: 'sim-extension', + version: 1, + type: 'focus-composer', + sessionId: sessionRef.current, + } + frameRef.current?.contentWindow?.postMessage(message, SIM_ORIGIN) + if (!document.hasFocus()) setStatus(KEYBOARD_FOCUS_HINT) + } + function invalidateSession() { + unreadableSelection.current = null + focusRequested.current = false + referenceRef.current?.reset() + setStatus((status) => (typeof status === 'string' ? status : '')) intentCutoffRef.current = Date.now() setAccessOpen(false) + setShortcutsOpen(false) if (pendingRef.current || busyRef.current) setStatus('The conversation changed before delivery. Attach the page again.') captureGenerationRef.current++ pendingRef.current = null deliveredRef.current = null sessionRef.current = null + connectedRef.current = false busyRef.current = false } async function capture( kind: 'page' | 'selection', - expected?: { tabId: number; documentId: string; urlDigest: string } + expected?: { + tabId: number + documentId: string + urlDigest: string + windowId: number + focusComposer?: boolean + } ) { + if (referenceRef.current?.busy()) { + setStatus('Wait for the current message to finish attaching its page.') + return + } if (busyRef.current) return if (pendingRef.current) { setStatus('Waiting for Sim to receive the previous capture. Try again after it appears.') @@ -82,17 +147,15 @@ function SidePanel({ origin }: SidePanelProps) { try { const snapshot = await captureActivePage(kind, expected) if (generation !== captureGenerationRef.current) return + focusRequested.current = expected?.focusComposer === true + unreadableSelection.current = null pendingRef.current = snapshot setStatus('Waiting for Sim to receive the attachment…') deliver() } catch (error) { - if (generation === captureGenerationRef.current) - setStatus( - getErrorMessage( - error, - 'Could not read this page. Click the Sim toolbar icon and try again.' - ) - ) + if (generation !== captureGenerationRef.current) return + if (error instanceof UnreadableSelectionError) unreadableSelection.current = error + setStatus(error instanceof PageCaptureError ? error : captureErrorMessage(error)) } finally { if (generation === captureGenerationRef.current) { busyRef.current = false @@ -102,6 +165,23 @@ function SidePanel({ origin }: SidePanelProps) { useEffect(() => { let disposed = false + const currentWindow = chrome.windows.getCurrent() + const manualIntents = new Set() + let processingManual = false + const reference = createReferenceCapture({ + session: () => sessionRef.current, + post: (message) => frameRef.current?.contentWindow?.postMessage(message, SIM_ORIGIN), + manualCaptureError: () => { + if (busyRef.current || pendingRef.current || processingManual || manualIntents.size) + return { + message: 'A page attachment is still being prepared. Try again once it appears.', + } + if (unreadableSelection.current) return { message: BLOCKED_SELECTION, reason: 'selection' } + }, + showStatus: setStatus, + clearAccessStatus: () => setStatus((status) => (typeof status === 'string' ? status : '')), + }) + referenceRef.current = reference let appTheme: 'light' | 'dark' | undefined const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') const applyTheme = () => { @@ -111,36 +191,46 @@ function SidePanel({ origin }: SidePanelProps) { } applyTheme() systemTheme.addEventListener('change', applyTheme) - let metadataGeneration = 0 - const updateSite = async () => { - const generation = ++metadataGeneration - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) - let next: { origin: string; allowed: boolean } | null = null - if (tab?.url && !tab.incognito) { - const url = new URL(tab.url) - if (['http:', 'https:'].includes(url.protocol) && url.origin !== SIM_ORIGIN) { - next = { - origin: url.origin, - allowed: await chrome.permissions.contains({ origins: [`${url.origin}/*`] }), - } - } - } - if (!disposed && generation === metadataGeneration) setSite(next) + const clearFocusHint = () => { + if (document.hasFocus()) + setStatus((current) => (current === KEYBOARD_FOCUS_HINT ? '' : current)) } - const update = () => { - void updateSite().catch(() => { - if (!disposed) setSite(null) - }) + const clearBlockedSelection = () => { + unreadableSelection.current = null + setStatus((current) => + current instanceof UnreadableSelectionError || current === BLOCKED_SELECTION ? '' : current + ) + } + const activated = ({ tabId, windowId }: chrome.tabs.OnActivatedInfo) => { + const blocked = unreadableSelection.current + if (blocked?.windowId === windowId && blocked.tabId !== tabId) clearBlockedSelection() + } + const navigated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { + if ( + unreadableSelection.current?.tabId === tabId && + (change.status === 'loading' || change.url) + ) + clearBlockedSelection() } let consumingIntent = false + const currentIntent = (intent: CaptureIntent, windowId: number | undefined) => { + const age = Date.now() - intent.createdAt + return ( + intent.windowId === windowId && + intent.createdAt > intentCutoffRef.current && + age >= 0 && + age <= CAPTURE_INTENT_TTL_MS + ) + } const consumeIntent = async () => { - if (disposed || consumingIntent || busyRef.current || pendingRef.current) return + if (disposed || consumingIntent || reference.busy() || busyRef.current || pendingRef.current) + return consumingIntent = true try { - const window = await chrome.windows.getCurrent() + const window = await currentWindow if (window.id === undefined) return const stored = await chrome.storage.session.get(null) - if (disposed || busyRef.current || pendingRef.current) return + if (disposed || reference.busy() || busyRef.current || pendingRef.current) return const keys = Object.keys(stored).filter((key) => key.startsWith(`${CAPTURE_INTENT_KEY}:${window.id}:`) ) @@ -150,7 +240,11 @@ function SidePanel({ origin }: SidePanelProps) { return intent.success && intent.data.windowId === window.id ? [intent.data] : [] }) .sort((left, right) => right.createdAt - left.createdAt) + processingManual = intents.some( + (intent) => intent.kind !== 'focus' && currentIntent(intent, window.id) + ) if (keys.length) await chrome.storage.session.remove(keys) + for (const key of keys) manualIntents.delete(key) const intent = intents[0] if (intent && intent.createdAt <= intentCutoffRef.current) { if (!disposed) @@ -160,14 +254,21 @@ function SidePanel({ origin }: SidePanelProps) { return } if (disposed || !intent) return - if (Date.now() - intent.createdAt > CAPTURE_INTENT_TTL_MS) { + if (!currentIntent(intent, window.id)) { setStatus('The capture expired. Attach the page again.') return } - await capture(intent.kind, intent) + if (intent.kind === 'focus') { + const [tab] = await chrome.tabs.query({ active: true, windowId: intent.windowId }) + if (!disposed && tab?.id === intent.tabId && intent.createdAt > intentCutoffRef.current) { + focusRequested.current = true + deliverFocus() + } + } else await capture(intent.kind, intent) } catch { if (!disposed) setStatus('Could not receive the capture request. Attach the page again.') } finally { + processingManual = false consumingIntent = false } } @@ -175,8 +276,55 @@ function SidePanel({ origin }: SidePanelProps) { changes: Record, area: string ) => { - if (area === 'session' && Object.values(changes).some((change) => change.newValue)) - void consumeIntent() + if (area === 'local' && REFERENCE_PAGE_KEY in changes) { + if (changes[REFERENCE_PAGE_KEY].newValue !== true) { + clearBlockedSelection() + } + reference.preferenceChanged(changes[REFERENCE_PAGE_KEY].newValue) + } + if (area === 'session') + void currentWindow.then((window) => { + if (disposed) return + for (const [key, change] of Object.entries(changes)) { + if (!key.startsWith(`${CAPTURE_INTENT_KEY}:${window.id}:`)) continue + const intent = captureIntentSchema.safeParse(change.newValue) + if ( + intent.success && + intent.data.kind !== 'focus' && + currentIntent(intent.data, window.id) + ) { + manualIntents.add(key) + reference.manualRequested() + } else manualIntents.delete(key) + } + if (Object.values(changes).some((change) => change.newValue)) void consumeIntent() + }) + } + let handshake = 0 + const connect = async (sessionId: string) => { + const current = ++handshake + if (sessionRef.current && sessionRef.current !== sessionId) invalidateSession() + sessionRef.current = sessionId + connectedRef.current = false + if (!(await reference.refreshPreference(sessionId))) return + if (disposed || handshake !== current || sessionRef.current !== sessionId) return + connectedRef.current = true + const connected: ExtensionToAppMessage = { + channel: 'sim-extension', + version: 1, + type: 'connected', + sessionId, + } + frameRef.current?.contentWindow?.postMessage(connected, SIM_ORIGIN) + const shortcuts: ExtensionToAppMessage = { + channel: 'sim-extension', + version: 1, + type: 'shortcuts-available', + sessionId, + } + frameRef.current?.contentWindow?.postMessage(shortcuts, SIM_ORIGIN) + deliver() + if (!pendingRef.current) deliverFocus() } const message = (event: MessageEvent) => { if (event.origin !== SIM_ORIGIN || event.source !== frameRef.current?.contentWindow) return @@ -186,27 +334,28 @@ function SidePanel({ origin }: SidePanelProps) { appTheme = parsed.data.theme applyTheme() } else if (parsed.data.type === 'ready') { - if (sessionRef.current && sessionRef.current !== parsed.data.sessionId) invalidateSession() - sessionRef.current = parsed.data.sessionId - const connected: ExtensionToAppMessage = { - channel: 'sim-extension', - version: 1, - type: 'connected', - sessionId: parsed.data.sessionId, - } - frameRef.current?.contentWindow?.postMessage(connected, SIM_ORIGIN) - deliver() + void connect(parsed.data.sessionId) } else if (parsed.data.sessionId !== sessionRef.current) { return + } else if (parsed.data.type === 'unavailable') { + invalidateSession() + } else if (!connectedRef.current) { + return + } else if (parsed.data.type === 'composer-focused') { + setStatus((current) => (current === KEYBOARD_FOCUS_HINT ? '' : current)) + } else if (parsed.data.type === 'set-reference-preference') { + void reference.setPreference(parsed.data.enabled) + } else if (parsed.data.type === 'capture-for-send') { + void reference.capture(parsed.data.sessionId, parsed.data.requestId) } else if (parsed.data.type === 'action') { if (!navigator.userActivation.isActive) return if (parsed.data.action === 'site-access') setAccessOpen(true) + else if (parsed.data.action === 'browser-shortcuts') setShortcutsOpen(true) else void capture(parsed.data.action === 'capture-page' ? 'page' : 'selection') - } else if (parsed.data.type === 'unavailable') { - invalidateSession() } else if (pendingRef.current?.id === parsed.data.snapshotId) { pendingRef.current = null setStatus('') + deliverFocus() void consumeIntent() } } @@ -216,67 +365,53 @@ function SidePanel({ origin }: SidePanelProps) { Date.now() - Date.parse(pendingRef.current.capturedAt) > CAPTURE_INTENT_TTL_MS ) { pendingRef.current = null + focusRequested.current = false setStatus('The capture expired. Attach the page again.') } void consumeIntent() }, 5_000) - const tabUpdated = ( - _tabId: number, - change: chrome.tabs.OnUpdatedInfo, - tab: chrome.tabs.Tab - ) => { - if (!tab.active || (change.url === undefined && change.status === undefined)) return - update() - } + window.addEventListener('focus', clearFocusHint, true) + chrome.tabs.onActivated.addListener(activated) + chrome.tabs.onUpdated.addListener(navigated) window.addEventListener('message', message) chrome.storage.onChanged.addListener(storageChanged) - chrome.tabs.onActivated.addListener(update) - chrome.tabs.onUpdated.addListener(tabUpdated) - chrome.permissions.onAdded.addListener(update) - chrome.permissions.onRemoved.addListener(update) - update() void consumeIntent() return () => { disposed = true captureGenerationRef.current++ pendingRef.current = null sessionRef.current = null + connectedRef.current = false window.clearInterval(expire) systemTheme.removeEventListener('change', applyTheme) + window.removeEventListener('focus', clearFocusHint, true) + chrome.tabs.onActivated.removeListener(activated) + chrome.tabs.onUpdated.removeListener(navigated) window.removeEventListener('message', message) chrome.storage.onChanged.removeListener(storageChanged) - chrome.tabs.onActivated.removeListener(update) - chrome.tabs.onUpdated.removeListener(tabUpdated) - chrome.permissions.onAdded.removeListener(update) - chrome.permissions.onRemoved.removeListener(update) + reference.reset() + referenceRef.current = null } }, []) - async function toggleSite() { - if (!site) return - const origins = [`${site.origin}/*`] - try { - const changed = site.allowed - ? await chrome.permissions.remove({ origins }) - : await chrome.permissions.request({ origins }) - if (!changed) - setStatus('Site access was not changed. You can still use the Sim toolbar icon.') - else setStatus('') - } catch { - setStatus('Could not change site access. Use Chrome’s extension settings.') - } - } - return (
-

- {status} -

+ {typeof status === 'string' ? status : status.message} + {status instanceof SiteAccessRequiredError && ( + <> + setAccessOpen(true)}> + Request site access + + + + )} +