diff --git a/.dockerignore b/.dockerignore index 8fc469dd1..bc05d817a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,9 @@ **/traces **/inputs **/har +**/downloads +**/browser_sessions +**/credential_vault **/.git **/.github **/.mypy_cache diff --git a/.env.example b/.env.example index d95ee4f41..8de4fcc0b 100644 --- a/.env.example +++ b/.env.example @@ -86,8 +86,9 @@ YUTORI_TOOL_SET="" # LLM_KEY: The chosen language model to use. This should be one of the models # provided by the enabled LLM providers (e.g., OPENAI_GPT5_5, OPENAI_GPT5_4, -# ANTHROPIC_CLAUDE4.7_OPUS, ANTHROPIC_CLAUDE4.6_SONNET, GEMINI_3_PRO, -# BEDROCK_ANTHROPIC_CLAUDE4.7_OPUS_INFERENCE_PROFILE). +# ANTHROPIC_CLAUDE5_FABLE, ANTHROPIC_CLAUDE4.7_OPUS, ANTHROPIC_CLAUDE4.6_SONNET, +# GEMINI_3_PRO, BEDROCK_ANTHROPIC_CLAUDE5_FABLE_INFERENCE_PROFILE, +# BEDROCK_ANTHROPIC_CLAUDE5_FABLE_WITH_FALLBACK). # See docs: https://www.skyvern.com/docs/self-hosted/llm-configuration LLM_KEY="" # a cheaper LLM providers to help finishing some small tasks, like custom selection or svg conversion. If empty, it will be the same as LLM_KEY @@ -138,12 +139,29 @@ ANALYTICS_ID="anonymous" # OP_SERVICE_ACCOUNT_TOKEN: API token for 1Password integration OP_SERVICE_ACCOUNT_TOKEN="" +# ============================================================================= +# SKYVERN CREDENTIAL VAULT CONFIGURATION +# ============================================================================= +# Built-in encrypted local vault used by default by the OSS Docker Compose setup. +# Docker also sets ENABLE_LOCAL_CREDENTIAL_VAULT=true and persists this under /data/credential_vault. +CREDENTIAL_VAULT_TYPE=skyvern +ENABLE_LOCAL_CREDENTIAL_VAULT=true +# LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault +# Production self-hosters should set LOCAL_CREDENTIAL_VAULT_KEY from an external secret store. +# If unset, Skyvern generates .fernet_key inside LOCAL_CREDENTIAL_VAULT_PATH; backups or host-dir +# exposure of that directory include both encrypted items and the key needed to decrypt them. +# LOCAL_CREDENTIAL_VAULT_KEY= +# The OSS Docker volumes for browser_sessions/ and downloads/ can contain plaintext cookies, +# session tokens, and downloaded files. Treat them as sensitive and exclude them from casual backups. + # Enable recording skyvern logs as artifacts ENABLE_LOG_ARTIFACTS=false # ============================================================================= # SKYVERN BITWARDEN CONFIGURATION # ============================================================================= +# Set CREDENTIAL_VAULT_TYPE=bitwarden to use Bitwarden or vaultwarden instead +# of the built-in Skyvern credential vault. # Your organization ID in official Bitwarden server or vaultwarden (if using organizations) SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID=your-org-id-here @@ -178,3 +196,27 @@ SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET=your-client-secret-here # Optional: override Redis URL specifically for notifications (falls back to REDIS_URL) # NOTIFICATION_REDIS_URL= # REDIS_URL=redis://localhost:6379/0 + +# ============================================================================= +# OPTIONAL: GCP SELF-HOSTED DEPLOYMENT +# ============================================================================= +# Uncomment to run Skyvern on Google Cloud with GCS storage and Secret Manager +# credentials. Auth uses Application Default Credentials (Workload Identity on +# GKE, or GOOGLE_APPLICATION_CREDENTIALS pointing at a service-account key). + +# Store artifacts/screenshots/browser sessions/uploads in Google Cloud Storage +# SKYVERN_STORAGE_TYPE=gcs +# GCS_PROJECT_ID=your-gcp-project-id +# GCS_BUCKET_ARTIFACTS=skyvern-artifacts +# GCS_BUCKET_SCREENSHOTS=skyvern-screenshots +# GCS_BUCKET_BROWSER_SESSIONS=skyvern-browser-sessions +# GCS_BUCKET_UPLOADS=skyvern-uploads +# Service account that signs GCS download URLs; required only under Workload +# Identity, where the runtime SA has no signing key of its own. +# GCS_SIGNER_SA_EMAIL=skyvern@your-gcp-project-id.iam.gserviceaccount.com + +# Store workflow credentials in GCP Secret Manager instead of Bitwarden +# CREDENTIAL_VAULT_TYPE=gcp +# GCP_CREDENTIAL_VAULT_PROJECT_ID=your-gcp-project-id +# Secret-id prefix; must be unique per Skyvern deployment sharing a project +# GCP_CREDENTIAL_VAULT_PREFIX=skyvern-cred- diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml index b483f839a..bb9160810 100644 --- a/.github/workflows/sdk-release.yml +++ b/.github/workflows/sdk-release.yml @@ -12,37 +12,53 @@ jobs: check-version-change: runs-on: ubuntu-latest outputs: - version_changed: ${{ steps.check.outputs.version_changed }} + should_publish: ${{ steps.check.outputs.should_publish }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 2 persist-credentials: false - - name: Check if version changed + - name: Decide whether to publish id: check run: | - # Get version from current pyproject.toml + # Compare current vs previous commit, AND check PyPI for the new version. + # Without the PyPI check, reverts (1.0.37 -> 1.0.36) and re-bumps after a + # revert (1.0.36 -> 1.0.37, where 1.0.37 was already published) both look + # like "version changed" and crash the publish step with HTTP 400. CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') - # Get version from previous commit git checkout HEAD^1 PREVIOUS_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') - if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then - echo "Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION" - echo "version_changed=true" >> $GITHUB_OUTPUT + if [ "$CURRENT_VERSION" = "$PREVIOUS_VERSION" ]; then + echo "Version unchanged at $CURRENT_VERSION; skipping publish." + echo "should_publish=false" >> $GITHUB_OUTPUT + exit 0 + fi + echo "Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION." + + # Treat both skyvern and skyvern-ui as already-published only if both + # are present on PyPI at the new version. If either is missing (e.g. + # the first release of a new sibling package), proceed. + PYPI_INDEX="https://pypi.org/pypi" + SKYVERN_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PYPI_INDEX/skyvern/$CURRENT_VERSION/json") + SKYVERN_UI_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PYPI_INDEX/skyvern-ui/$CURRENT_VERSION/json") + echo "PyPI lookup: skyvern=$SKYVERN_STATUS skyvern-ui=$SKYVERN_UI_STATUS" + + if [ "$SKYVERN_STATUS" = "200" ] && [ "$SKYVERN_UI_STATUS" = "200" ]; then + echo "Both packages already at $CURRENT_VERSION on PyPI; skipping publish." + echo "should_publish=false" >> $GITHUB_OUTPUT else - echo "Version remained at $CURRENT_VERSION" - echo "version_changed=false" >> $GITHUB_OUTPUT + echo "should_publish=true" >> $GITHUB_OUTPUT fi run-ci: needs: check-version-change - if: needs.check-version-change.outputs.version_changed == 'true' + if: needs.check-version-change.outputs.should_publish == 'true' uses: ./.github/workflows/ci.yml build-sdk: runs-on: ubuntu-latest needs: [check-version-change, run-ci] - if: needs.check-version-change.outputs.version_changed == 'true' + if: needs.check-version-change.outputs.should_publish == 'true' steps: - name: Check out Git repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -123,7 +139,12 @@ jobs: uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: packages-dir: dist/skyvern-ui/ + # Defense-in-depth: if check-version-change ever misses an edge case + # (e.g. half-published release where one sibling landed), skip files + # already on PyPI rather than failing the whole publish step. + skip-existing: true - name: Publish skyvern to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: packages-dir: dist/skyvern/ + skip-existing: true diff --git a/.gitignore b/.gitignore index b3f08ff9e..86172c6f3 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ ig_* # Skyvern ignores browser_sessions/ +credential_vault/ videos/ skyvern/artifacts/ artifacts/ @@ -189,6 +190,7 @@ tags.temp.tmp # copy of Chrome user profile from Chrome >= 136 tmp/user_data_dir +tmp/ # Claude .claude/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c5da55e0..db865b331 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -132,3 +132,6 @@ repos: rev: v0.17.2 hooks: - id: yamlfmt + # Helm chart templates contain Go templating ({{ ... }}) and are not + # valid standalone YAML, so yamlfmt cannot parse them. + exclude: ^k8s/charts/.*/templates/ diff --git a/Dockerfile b/Dockerfile index 351c3d5a5..d954a1565 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN uv pip compile pyproject.toml --extra server --python-version 3.11 -o requir FROM python:3.11-slim-bookworm WORKDIR /app COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt +COPY ./skyvern/forge/sdk/utils/tesseract_languages.py /tmp/tesseract_languages.py RUN pip install --upgrade pip setuptools wheel # --no-deps: requirements.txt is fully resolved by uv, including the # pyproject overrides that loosen litellm's jsonschema==4.23.0 pin. @@ -18,7 +19,13 @@ RUN pip install --upgrade pip setuptools wheel RUN pip install --no-cache-dir --no-deps -r requirements.txt RUN playwright install-deps RUN playwright install -RUN apt-get install -y xauth x11-apps netpbm gpg ca-certificates x11vnc && apt-get clean +RUN apt-get update && \ + apt-get install -y xauth x11-apps netpbm gpg ca-certificates x11vnc tesseract-ocr $(python /tmp/tesseract_languages.py --apt-packages) && \ + tesseract --version && \ + rm /tmp/tesseract_languages.py && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + RUN pip install --no-cache-dir websockify COPY .nvmrc /app/.nvmrc @@ -47,6 +54,9 @@ ENV VIDEO_PATH=/data/videos ENV HAR_PATH=/data/har ENV LOG_PATH=/data/log ENV ARTIFACT_STORAGE_PATH=/data/artifacts +ENV DOWNLOAD_PATH=/data/downloads +ENV BROWSER_SESSION_BASE_PATH=/data/browser_sessions +ENV LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault # cache tiktoken RUN python /app/scripts/load_tiktoken.py diff --git a/alembic/versions/2026_06_14_2221-218048b68412_add_workflow_copilot_completion_criteria_sets_table.py b/alembic/versions/2026_06_14_2221-218048b68412_add_workflow_copilot_completion_criteria_sets_table.py new file mode 100644 index 000000000..0f44122f9 --- /dev/null +++ b/alembic/versions/2026_06_14_2221-218048b68412_add_workflow_copilot_completion_criteria_sets_table.py @@ -0,0 +1,65 @@ +"""add workflow_copilot_completion_criteria_sets table + +Revision ID: 218048b68412 +Revises: a8c4e2f9d6b1 +Create Date: 2026-06-14T22:21:36.243898+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "218048b68412" +down_revision: Union[str, None] = "a8c4e2f9d6b1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLE = "workflow_copilot_completion_criteria_sets" + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if inspector.has_table(_TABLE): + return + + if conn.dialect.name == "postgresql": + op.execute(sa.text("SET lock_timeout = '2s'")) + + op.create_table( + _TABLE, + sa.Column("completion_criteria_set_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("workflow_copilot_chat_id", sa.String(), nullable=False), + sa.Column("goal_epoch", sa.Integer(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("criteria", sa.JSON(), nullable=False), + sa.Column("source_turn_id", sa.String(), nullable=True), + sa.Column("source_goal_text", sa.UnicodeText(), nullable=True), + sa.Column("consecutive_all_no_evidence", sa.Integer(), nullable=False), + sa.Column("tripwire_fired", sa.Boolean(), nullable=False), + sa.Column("last_fully_satisfied_workflow_yaml", sa.UnicodeText(), nullable=True), + sa.Column("superseded_by_set_id", sa.String(), nullable=True), + sa.Column("superseded_at", sa.DateTime(), nullable=True), + sa.Column("supersede_reason", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("completion_criteria_set_id"), + ) + op.create_index("wcccs_org_chat_index", _TABLE, ["organization_id", "workflow_copilot_chat_id"]) + + if conn.dialect.name == "postgresql": + op.execute(sa.text("SET lock_timeout = '0'")) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if not inspector.has_table(_TABLE): + return + op.drop_index("wcccs_org_chat_index", table_name=_TABLE) + op.drop_table(_TABLE) diff --git a/alembic/versions/2026_06_16_0803-cfc2318acdf9_add_sequential_key_lookup_index_on_workflow_runs.py b/alembic/versions/2026_06_16_0803-cfc2318acdf9_add_sequential_key_lookup_index_on_workflow_runs.py new file mode 100644 index 000000000..cf7b44631 --- /dev/null +++ b/alembic/versions/2026_06_16_0803-cfc2318acdf9_add_sequential_key_lookup_index_on_workflow_runs.py @@ -0,0 +1,33 @@ +"""add sequential_key lookup index on workflow_runs + +Revision ID: cfc2318acdf9 +Revises: 218048b68412 +Create Date: 2026-06-16T08:03:01.419039+00:00 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "cfc2318acdf9" +down_revision: Union[str, None] = "218048b68412" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("SET statement_timeout = '24h';") + op.execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_workflow_runs_sequential_key_lookup + ON workflow_runs (workflow_permanent_id, sequential_key, queued_at) + WHERE status IN ('queued', 'running', 'paused') AND browser_session_id IS NULL + """) + op.execute("RESET statement_timeout;") + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_workflow_runs_sequential_key_lookup") diff --git a/alembic/versions/2026_06_16_1253-1fee32b3d7c6_add_browser_profile_loaded_to_persistent_browser_sessions.py b/alembic/versions/2026_06_16_1253-1fee32b3d7c6_add_browser_profile_loaded_to_persistent_browser_sessions.py new file mode 100644 index 000000000..8f50865b6 --- /dev/null +++ b/alembic/versions/2026_06_16_1253-1fee32b3d7c6_add_browser_profile_loaded_to_persistent_browser_sessions.py @@ -0,0 +1,30 @@ +"""add browser_profile_loaded to persistent_browser_sessions + +Revision ID: 1fee32b3d7c6 +Revises: cfc2318acdf9 +Create Date: 2026-06-16T12:53:28.144752+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1fee32b3d7c6" +down_revision: Union[str, None] = "cfc2318acdf9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "persistent_browser_sessions", + sa.Column("browser_profile_loaded", sa.Boolean(), server_default=sa.true(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("persistent_browser_sessions", "browser_profile_loaded") diff --git a/alembic/versions/2026_06_19_2044-58b0ced36529_add_downloaded_file_count_to_workflow_run_blocks.py b/alembic/versions/2026_06_19_2044-58b0ced36529_add_downloaded_file_count_to_workflow_run_blocks.py new file mode 100644 index 000000000..4373544af --- /dev/null +++ b/alembic/versions/2026_06_19_2044-58b0ced36529_add_downloaded_file_count_to_workflow_run_blocks.py @@ -0,0 +1,28 @@ +"""add downloaded_file_count to workflow_run_blocks + +Revision ID: 58b0ced36529 +Revises: 1fee32b3d7c6 +Create Date: 2026-06-19T20:44:46.548990+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "58b0ced36529" +down_revision: Union[str, None] = "1fee32b3d7c6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.add_column("workflow_run_blocks", sa.Column("downloaded_file_count", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("workflow_run_blocks", "downloaded_file_count") diff --git a/alembic/versions/2026_06_22_1122-61c9acf9f3ba_add_copilot_chat_history_indexes.py b/alembic/versions/2026_06_22_1122-61c9acf9f3ba_add_copilot_chat_history_indexes.py new file mode 100644 index 000000000..2ff72e342 --- /dev/null +++ b/alembic/versions/2026_06_22_1122-61c9acf9f3ba_add_copilot_chat_history_indexes.py @@ -0,0 +1,37 @@ +"""add copilot chat history indexes + +Revision ID: 61c9acf9f3ba +Revises: 58b0ced36529 +Create Date: 2026-06-22 11:22:49.851792+00:00 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "61c9acf9f3ba" +down_revision: Union[str, None] = "58b0ced36529" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("SET statement_timeout = '1h';") + op.execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS wccm_org_chat_index + ON workflow_copilot_chat_messages (organization_id, workflow_copilot_chat_id); + """) + op.execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS wcc_org_created_at_index + ON workflow_copilot_chats (organization_id, created_at); + """) + op.execute("RESET statement_timeout;") + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("DROP INDEX CONCURRENTLY IF EXISTS wccm_org_chat_index;") + op.execute("DROP INDEX CONCURRENTLY IF EXISTS wcc_org_created_at_index;") diff --git a/alembic/versions/2026_06_24_1630-2c76348b4e7a_add_tag_values_table.py b/alembic/versions/2026_06_24_1630-2c76348b4e7a_add_tag_values_table.py new file mode 100644 index 000000000..27c19e1d3 --- /dev/null +++ b/alembic/versions/2026_06_24_1630-2c76348b4e7a_add_tag_values_table.py @@ -0,0 +1,54 @@ +"""add tag values table + +Revision ID: 2c76348b4e7a +Revises: 61c9acf9f3ba +Create Date: 2026-06-24T16:30:09.366617+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2c76348b4e7a" +down_revision: Union[str, None] = "61c9acf9f3ba" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "tag_values", + sa.Column("tag_value_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("key", sa.String(), nullable=False), + sa.Column("value", sa.String(), nullable=False), + sa.Column("color", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.organization_id"], + ), + sa.PrimaryKeyConstraint("tag_value_id"), + ) + op.create_index( + "ix_tag_values_org_key_value_active", + "tag_values", + ["organization_id", "key", "value"], + unique=True, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index( + "ix_tag_values_org_key_value_active", + table_name="tag_values", + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.drop_table("tag_values") diff --git a/alembic/versions/2026_06_25_0017-45128d67bc1a_add_browser_profile_key_to_workflows.py b/alembic/versions/2026_06_25_0017-45128d67bc1a_add_browser_profile_key_to_workflows.py new file mode 100644 index 000000000..f0db01467 --- /dev/null +++ b/alembic/versions/2026_06_25_0017-45128d67bc1a_add_browser_profile_key_to_workflows.py @@ -0,0 +1,27 @@ +"""add browser_profile_key to workflows + +Revision ID: 45128d67bc1a +Revises: 2c76348b4e7a +Create Date: 2026-06-25T00:17:17.827852+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "45128d67bc1a" +down_revision: Union[str, None] = "2c76348b4e7a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("workflows", sa.Column("browser_profile_key", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("workflows", "browser_profile_key") diff --git a/alembic/versions/2026_06_26_1926-a1cad008154a_add_proxy_pin_fields.py b/alembic/versions/2026_06_26_1926-a1cad008154a_add_proxy_pin_fields.py new file mode 100644 index 000000000..8a2a443bb --- /dev/null +++ b/alembic/versions/2026_06_26_1926-a1cad008154a_add_proxy_pin_fields.py @@ -0,0 +1,36 @@ +"""add proxy pin fields + +Revision ID: a1cad008154a +Revises: 45128d67bc1a +Create Date: 2026-06-26T19:26:11.232122+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1cad008154a" +down_revision: Union[str, None] = "45128d67bc1a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.add_column("credentials", sa.Column("proxy_location", sa.String(), nullable=True)) + op.add_column("credentials", sa.Column("proxy_session_id", sa.String(), nullable=True)) + op.add_column("browser_profiles", sa.Column("proxy_location", sa.String(), nullable=True)) + op.add_column("browser_profiles", sa.Column("proxy_session_id", sa.String(), nullable=True)) + op.add_column("persistent_browser_sessions", sa.Column("proxy_session_id", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("persistent_browser_sessions", "proxy_session_id") + op.drop_column("browser_profiles", "proxy_session_id") + op.drop_column("browser_profiles", "proxy_location") + op.drop_column("credentials", "proxy_session_id") + op.drop_column("credentials", "proxy_location") diff --git a/alembic/versions/2026_07_02_2317-ecf365563e98_managed_browser_profiles_for_save_and_reuse_session.py b/alembic/versions/2026_07_02_2317-ecf365563e98_managed_browser_profiles_for_save_and_reuse_session.py new file mode 100644 index 000000000..de4304f7d --- /dev/null +++ b/alembic/versions/2026_07_02_2317-ecf365563e98_managed_browser_profiles_for_save_and_reuse_session.py @@ -0,0 +1,67 @@ +"""managed browser profiles for save and reuse session + +Revision ID: ecf365563e98 +Revises: a1cad008154a +Create Date: 2026-07-02T23:17:33.611632+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "ecf365563e98" +down_revision: Union[str, None] = "a1cad008154a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "browser_profiles", + sa.Column("is_managed", sa.Boolean(), server_default=sa.text("false"), nullable=False), + ) + op.add_column("browser_profiles", sa.Column("workflow_permanent_id", sa.String(), nullable=True)) + op.add_column("browser_profiles", sa.Column("browser_profile_key_digest", sa.String(), nullable=True)) + op.create_index( + "uq_browser_profiles_org_name_user", + "browser_profiles", + ["organization_id", "name"], + unique=True, + postgresql_where=sa.text("is_managed = false"), + sqlite_where=sa.text("is_managed = false"), + ) + op.create_index( + "uq_browser_profiles_managed_segment", + "browser_profiles", + ["organization_id", "workflow_permanent_id", "browser_profile_key_digest"], + unique=True, + # Exclude soft-deleted rows so deleting a managed profile doesn't tombstone the + # segment — the next run re-creates it instead of colliding on the unique key. + postgresql_where=sa.text("is_managed = true AND deleted_at IS NULL"), + sqlite_where=sa.text("is_managed = true AND deleted_at IS NULL"), + ) + op.create_index("idx_browser_profiles_wpid", "browser_profiles", ["workflow_permanent_id"], unique=False) + op.drop_constraint("uc_org_browser_profile_name", "browser_profiles", type_="unique") + + +def downgrade() -> None: + op.execute("DELETE FROM browser_profiles WHERE is_managed = true") + op.create_unique_constraint("uc_org_browser_profile_name", "browser_profiles", ["organization_id", "name"]) + op.drop_index("idx_browser_profiles_wpid", table_name="browser_profiles") + op.drop_index( + "uq_browser_profiles_managed_segment", + table_name="browser_profiles", + postgresql_where=sa.text("is_managed = true AND deleted_at IS NULL"), + ) + op.drop_index( + "uq_browser_profiles_org_name_user", + table_name="browser_profiles", + postgresql_where=sa.text("is_managed = false"), + ) + op.drop_column("browser_profiles", "browser_profile_key_digest") + op.drop_column("browser_profiles", "workflow_permanent_id") + op.drop_column("browser_profiles", "is_managed") diff --git a/alembic/versions/2026_07_03_0333-2ac47bc1c075_add_enable_self_healing_to_workflows.py b/alembic/versions/2026_07_03_0333-2ac47bc1c075_add_enable_self_healing_to_workflows.py new file mode 100644 index 000000000..3217cbd8b --- /dev/null +++ b/alembic/versions/2026_07_03_0333-2ac47bc1c075_add_enable_self_healing_to_workflows.py @@ -0,0 +1,30 @@ +"""add enable_self_healing to workflows + +Revision ID: 2ac47bc1c075 +Revises: ecf365563e98 +Create Date: 2026-07-03T03:33:20.863940+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2ac47bc1c075" +down_revision: Union[str, None] = "ecf365563e98" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "workflows", + sa.Column("enable_self_healing", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade() -> None: + op.drop_column("workflows", "enable_self_healing") diff --git a/alembic/versions/2026_07_03_1853-aff1632dc377_add_workflow_run_credential_selections.py b/alembic/versions/2026_07_03_1853-aff1632dc377_add_workflow_run_credential_selections.py new file mode 100644 index 000000000..814f1a24d --- /dev/null +++ b/alembic/versions/2026_07_03_1853-aff1632dc377_add_workflow_run_credential_selections.py @@ -0,0 +1,49 @@ +"""add workflow run credential selections + +Revision ID: aff1632dc377 +Revises: 2ac47bc1c075 +Create Date: 2026-07-03T18:53:23.324057+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "aff1632dc377" +down_revision: Union[str, None] = "2ac47bc1c075" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("credential_parameters", sa.Column("credential_ids", sa.JSON(), nullable=True)) + op.add_column("credential_parameters", sa.Column("selection_strategy", sa.String(), nullable=True)) + op.create_table( + "workflow_run_credential_selections", + sa.Column("selection_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("workflow_run_id", sa.String(), nullable=False), + sa.Column("workflow_permanent_id", sa.String(), nullable=False), + sa.Column("parameter_key", sa.String(), nullable=False), + sa.Column("credential_id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("selection_id"), + sa.UniqueConstraint("workflow_run_id", "parameter_key", name="uq_wrcs_workflow_run_parameter_key"), + ) + op.create_index( + "idx_wrcs_lru_lookup", + "workflow_run_credential_selections", + ["organization_id", "workflow_permanent_id", "parameter_key", "credential_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("idx_wrcs_lru_lookup", table_name="workflow_run_credential_selections") + op.drop_table("workflow_run_credential_selections") + op.drop_column("credential_parameters", "selection_strategy") + op.drop_column("credential_parameters", "credential_ids") diff --git a/alembic/versions/2026_07_07_1535-e2d87251fee8_browser_profile_name_unique_partial_on_deleted_at.py b/alembic/versions/2026_07_07_1535-e2d87251fee8_browser_profile_name_unique_partial_on_deleted_at.py new file mode 100644 index 000000000..0ac0a82ca --- /dev/null +++ b/alembic/versions/2026_07_07_1535-e2d87251fee8_browser_profile_name_unique_partial_on_deleted_at.py @@ -0,0 +1,51 @@ +"""browser profile name unique partial on deleted_at + +Revision ID: e2d87251fee8 +Revises: aff1632dc377 +Create Date: 2026-07-07T15:35:51.042177+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e2d87251fee8" +down_revision: Union[str, None] = "aff1632dc377" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_index( + "uq_browser_profiles_org_name_user", + table_name="browser_profiles", + postgresql_where=sa.text("is_managed = false"), + ) + op.create_index( + "uq_browser_profiles_org_name_user", + "browser_profiles", + ["organization_id", "name"], + unique=True, + postgresql_where=sa.text("is_managed = false AND deleted_at IS NULL"), + sqlite_where=sa.text("is_managed = false AND deleted_at IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index( + "uq_browser_profiles_org_name_user", + table_name="browser_profiles", + postgresql_where=sa.text("is_managed = false AND deleted_at IS NULL"), + ) + op.create_index( + "uq_browser_profiles_org_name_user", + "browser_profiles", + ["organization_id", "name"], + unique=True, + postgresql_where=sa.text("is_managed = false"), + sqlite_where=sa.text("is_managed = false"), + ) diff --git a/alembic/versions/2026_07_07_2121-5b618ec6dce6_add_pin_saved_session_ip_to_workflows.py b/alembic/versions/2026_07_07_2121-5b618ec6dce6_add_pin_saved_session_ip_to_workflows.py new file mode 100644 index 000000000..4ae5beb91 --- /dev/null +++ b/alembic/versions/2026_07_07_2121-5b618ec6dce6_add_pin_saved_session_ip_to_workflows.py @@ -0,0 +1,31 @@ +"""add pin_saved_session_ip to workflows + +Revision ID: 5b618ec6dce6 +Revises: e2d87251fee8 +Create Date: 2026-07-07T21:21:37.964227+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5b618ec6dce6" +down_revision: Union[str, None] = "e2d87251fee8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.add_column( + "workflows", + sa.Column("pin_saved_session_ip", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade() -> None: + op.drop_column("workflows", "pin_saved_session_ip") diff --git a/alembic/versions/2026_07_08_1754-bec06d149264_add_workflow_run_tag_events_table.py b/alembic/versions/2026_07_08_1754-bec06d149264_add_workflow_run_tag_events_table.py new file mode 100644 index 000000000..06fc42350 --- /dev/null +++ b/alembic/versions/2026_07_08_1754-bec06d149264_add_workflow_run_tag_events_table.py @@ -0,0 +1,133 @@ +"""add workflow run tag events table + +Revision ID: bec06d149264 +Revises: 5b618ec6dce6 +Create Date: 2026-07-08T17:54:31.712270+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "bec06d149264" +down_revision: Union[str, None] = "5b618ec6dce6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "workflow_run_tag_events", + sa.Column("tag_event_id", sa.String(), nullable=False), + sa.Column("workflow_run_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("key", sa.String(), nullable=True), + sa.Column("value", sa.String(), nullable=True), + sa.Column("event_type", sa.String(), nullable=False), + sa.Column("set_at", sa.DateTime(), nullable=False), + sa.Column("set_by", sa.String(), nullable=False), + sa.Column("source", sa.String(), nullable=False), + sa.Column("caller_type", sa.String(), nullable=True), + sa.Column("superseded_at", sa.DateTime(), nullable=True), + sa.Column("inherited_from_tag_event_id", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.CheckConstraint("event_type IN ('set', 'delete')", name="ck_workflow_run_tag_events_event_type"), + sa.CheckConstraint( + "source IN ('manual', 'bulk_apply', 'backfill', 'inherited', 'import', 'system')", + name="ck_workflow_run_tag_events_source", + ), + sa.CheckConstraint( + "caller_type IS NULL OR caller_type IN ('user', 'api_key', 'system')", + name="ck_workflow_run_tag_events_caller_type", + ), + sa.CheckConstraint( + "event_type != 'set' OR value IS NOT NULL", + name="ck_workflow_run_tag_events_set_has_value", + ), + sa.ForeignKeyConstraint( + ["workflow_run_id"], + ["workflow_runs.workflow_run_id"], + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.organization_id"], + ), + sa.PrimaryKeyConstraint("tag_event_id"), + ) + op.create_index( + "workflow_run_tag_events_org_wr_set_at_idx", + "workflow_run_tag_events", + ["organization_id", "workflow_run_id", "set_at"], + unique=False, + ) + op.create_index( + "workflow_run_tag_events_org_key_value_active_idx", + "workflow_run_tag_events", + ["organization_id", "key", "value"], + unique=False, + postgresql_include=["workflow_run_id"], + postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"), + ) + op.create_index( + "workflow_run_tag_events_org_value_active_idx", + "workflow_run_tag_events", + ["organization_id", "value"], + unique=False, + postgresql_include=["workflow_run_id"], + postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"), + ) + op.create_index( + "workflow_run_tag_events_org_set_at_idx", + "workflow_run_tag_events", + ["organization_id", "set_at"], + unique=False, + ) + op.create_index( + "workflow_run_tag_events_active_grouped_unique", + "workflow_run_tag_events", + ["organization_id", "workflow_run_id", "key"], + unique=True, + postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"), + sqlite_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"), + ) + op.create_index( + "workflow_run_tag_events_active_label_unique", + "workflow_run_tag_events", + ["organization_id", "workflow_run_id", "value"], + unique=True, + postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"), + sqlite_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index( + "workflow_run_tag_events_active_label_unique", + table_name="workflow_run_tag_events", + postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"), + ) + op.drop_index( + "workflow_run_tag_events_active_grouped_unique", + table_name="workflow_run_tag_events", + postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"), + ) + op.drop_index("workflow_run_tag_events_org_set_at_idx", table_name="workflow_run_tag_events") + op.drop_index( + "workflow_run_tag_events_org_value_active_idx", + table_name="workflow_run_tag_events", + postgresql_include=["workflow_run_id"], + postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"), + ) + op.drop_index( + "workflow_run_tag_events_org_key_value_active_idx", + table_name="workflow_run_tag_events", + postgresql_include=["workflow_run_id"], + postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"), + ) + op.drop_index("workflow_run_tag_events_org_wr_set_at_idx", table_name="workflow_run_tag_events") + op.drop_table("workflow_run_tag_events") diff --git a/alembic/versions/2026_07_09_2247-e1227914ecfe_add_heal_episodes_and_proposals.py b/alembic/versions/2026_07_09_2247-e1227914ecfe_add_heal_episodes_and_proposals.py new file mode 100644 index 000000000..25504ba4b --- /dev/null +++ b/alembic/versions/2026_07_09_2247-e1227914ecfe_add_heal_episodes_and_proposals.py @@ -0,0 +1,108 @@ +"""add heal episodes and proposals + +Revision ID: e1227914ecfe +Revises: bec06d149264 +Create Date: 2026-07-09T22:47:11.311011+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e1227914ecfe" +down_revision: Union[str, None] = "bec06d149264" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "heal_episodes", + sa.Column("heal_episode_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("workflow_permanent_id", sa.String(), nullable=False), + sa.Column("workflow_id", sa.String(), nullable=False), + sa.Column("workflow_run_id", sa.String(), nullable=False), + sa.Column("workflow_run_block_id", sa.String(), nullable=False), + sa.Column("block_label", sa.String(), nullable=False), + sa.Column("engine", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("skip_reason", sa.String(), nullable=True), + sa.Column("block_prompt", sa.UnicodeText(), nullable=True), + sa.Column("block_code", sa.UnicodeText(), nullable=True), + sa.Column("block_steps", sa.JSON(), nullable=True), + sa.Column("snapshot_available", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("convergence_eligible", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("parameter_binding_keys", sa.JSON(), nullable=True), + sa.Column("exception_class", sa.String(), nullable=True), + sa.Column("failing_line", sa.Integer(), nullable=True), + sa.Column("matched_step_index", sa.Integer(), nullable=True), + sa.Column("failure_message", sa.UnicodeText(), nullable=True), + sa.Column("escalation_task_id", sa.String(), nullable=True), + sa.Column("wall_clock_ms", sa.Integer(), nullable=True), + sa.Column("action_count", sa.Integer(), nullable=True), + sa.Column("output_obligation", sa.String(), nullable=True), + sa.Column("dom_snapshot_artifact_id", sa.String(), nullable=True), + sa.Column("scout_transcript_artifact_id", sa.String(), nullable=True), + sa.Column("screenshot_artifact_id", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("heal_episode_id"), + ) + op.create_index( + "he_org_wpid_index", + "heal_episodes", + ["organization_id", "workflow_permanent_id", "created_at"], + unique=False, + ) + op.create_index("he_org_created_at_index", "heal_episodes", ["organization_id", "created_at"], unique=False) + op.create_index("he_org_wrid_index", "heal_episodes", ["organization_id", "workflow_run_id"], unique=False) + + op.create_table( + "workflow_heal_proposals", + sa.Column("heal_proposal_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("workflow_permanent_id", sa.String(), nullable=False), + sa.Column("block_label", sa.String(), nullable=False), + sa.Column("candidate_definition", sa.JSON(), nullable=False), + sa.Column("provenance", sa.JSON(), nullable=True), + sa.Column("episode_ids", sa.JSON(), nullable=False), + sa.Column("rendered_diff", sa.UnicodeText(), nullable=True), + sa.Column("base_version", sa.Integer(), nullable=False), + sa.Column("base_definition_hash", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False, server_default=sa.text("'proposed'")), + sa.Column("adopted_workflow_id", sa.String(), nullable=True), + sa.Column("episode_window", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("heal_proposal_id"), + ) + op.create_index( + "hp_org_wpid_index", + "workflow_heal_proposals", + ["organization_id", "workflow_permanent_id"], + unique=False, + ) + + op.add_column( + "organizations", + sa.Column("selfheal_screenshot_capture_enabled", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column("organizations", sa.Column("selfheal_artifact_retention_days", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("organizations", "selfheal_artifact_retention_days") + op.drop_column("organizations", "selfheal_screenshot_capture_enabled") + + op.drop_index("hp_org_wpid_index", table_name="workflow_heal_proposals") + op.drop_table("workflow_heal_proposals") + + op.drop_index("he_org_wrid_index", table_name="heal_episodes") + op.drop_index("he_org_created_at_index", table_name="heal_episodes") + op.drop_index("he_org_wpid_index", table_name="heal_episodes") + op.drop_table("heal_episodes") diff --git a/docker-compose.yml b/docker-compose.yml index 5614266da..fb8999bdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,9 @@ services: - ./videos:/data/videos - ./har:/data/har - ./log:/data/log + - ./downloads:/data/downloads + - ./browser_sessions:/data/browser_sessions + - ./credential_vault:/data/credential_vault # Generated credentials allow the UI to pick up the local API key on first startup. - ./.skyvern:/app/.skyvern # Uncomment the following two lines if you want to connect to any local changes @@ -62,6 +65,11 @@ services: - BROWSER_REMOTE_DEBUGGING_URL=${BROWSER_REMOTE_DEBUGGING_URL:-http://127.0.0.1:9222} - BROWSER_REMOTE_DEBUGGING_HOST_HEADER=${BROWSER_REMOTE_DEBUGGING_HOST_HEADER:-} - BROWSER_CDP_CONNECT_TIMEOUT_MS=${BROWSER_CDP_CONNECT_TIMEOUT_MS:-120000} + - DOWNLOAD_PATH=/data/downloads + - BROWSER_SESSION_BASE_PATH=/data/browser_sessions + - CREDENTIAL_VAULT_TYPE=${CREDENTIAL_VAULT_TYPE:-skyvern} + - ENABLE_LOCAL_CREDENTIAL_VAULT=${ENABLE_LOCAL_CREDENTIAL_VAULT:-true} + - LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault - ENABLE_CODE_BLOCK=true # --- Control your own browser (Chrome/Chromium) --- # Prefer Chrome's chrome://inspect/#remote-debugging flow for an existing profile. diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index aea2cf7cd..3f216317b 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -1519,6 +1519,24 @@ "title": "Key" }, "description": "Tag key to delete" + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." } ], "responses": { @@ -1549,6 +1567,290 @@ "x-fern-sdk-method-name": "delete_tag_key" } }, + "/v1/tag-values": { + "get": { + "tags": [ + "Tags" + ], + "summary": "List tag values", + "description": "List the palette color and current workflow usage count for each grouped tag (key, value) for the organization. The frontend joins these onto tags by (key, value); workflow_count is the number of non-deleted workflows carrying the label and powers the per-label usage and delete blast-radius warnings.", + "operationId": "list_tag_values_v1_tag_values_get", + "parameters": [ + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "responses": { + "200": { + "description": "Successfully retrieved tag values", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagValue" + }, + "title": "Response List Tag Values V1 Tag Values Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-fern-sdk-method-name": "list_tag_values" + } + }, + "/v1/tag-values/{key}": { + "patch": { + "tags": [ + "Tags" + ], + "summary": "Update tag value color", + "description": "Recolor a grouped tag (key, value). The value is supplied in the body so values containing '/' stay addressable. The new color must be a palette name.", + "operationId": "update_tag_value_v1_tag_values__key__patch", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Tag key (group)", + "examples": [ + "env" + ], + "title": "Key" + }, + "description": "Tag key (group)" + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagValueUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully recolored tag value", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagValue" + } + } + } + }, + "404": { + "description": "Tag value not found" + }, + "422": { + "description": "Invalid palette color" + } + }, + "x-fern-sdk-method-name": "update_tag_value" + }, + "delete": { + "tags": [ + "Tags" + ], + "summary": "Delete tag value", + "description": "Soft-delete a grouped tag (key, value) and remove that label from every workflow carrying it (cascade). The value rides in the body so values containing '/' stay addressable. Returns how many workflows the label was removed from.", + "operationId": "delete_tag_value_v1_tag_values__key__delete", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Tag key (group)", + "examples": [ + "env" + ], + "title": "Key" + }, + "description": "Tag key (group)" + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagValueDelete" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully deleted tag value", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagValueDeleteResponse" + } + } + } + }, + "404": { + "description": "Tag value not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-fern-sdk-method-name": "delete_tag_value" + } + }, + "/v1/tag-values/{key}/rename": { + "patch": { + "tags": [ + "Tags" + ], + "summary": "Rename tag value", + "description": "Rename a grouped tag (key, value) to (key, new_value). The cascade re-tags every workflow carrying the old label; the new label inherits the old color. Both values ride in the body so values containing '/' stay addressable. Rejects with 409 when the new value already exists for the key.", + "operationId": "rename_tag_value_v1_tag_values__key__rename_patch", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Tag key (group)", + "examples": [ + "env" + ], + "title": "Key" + }, + "description": "Tag key (group)" + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagValueRename" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully renamed tag value", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagValueRenameResponse" + } + } + } + }, + "404": { + "description": "Tag value not found" + }, + "409": { + "description": "Target value already exists for this key" + }, + "422": { + "description": "Invalid value" + } + }, + "x-fern-sdk-method-name": "rename_tag_value" + } + }, "/v1/workflow-tags": { "get": { "tags": [ @@ -2241,7 +2543,7 @@ "Browser Profiles" ], "summary": "Create a browser profile", - "description": "Create a browser profile from a persistent browser session or workflow run.", + "description": "Create a blank browser profile, or create one from a persistent browser session or workflow run.", "operationId": "create_browser_profile_v1_browser_profiles_post", "parameters": [ { @@ -2285,7 +2587,7 @@ } }, "400": { - "description": "Invalid request - missing source or source not found" + "description": "Invalid request - source not found or source archive unavailable" }, "409": { "description": "Browser profile name already exists" @@ -2307,11 +2609,11 @@ "code-samples": [ { "sdk": "python", - "code": "from skyvern import Skyvern\n\nskyvern = Skyvern(api_key=\"YOUR_API_KEY\")\n# Create a browser profile from a persistent browser session\nbrowser_profile = await skyvern.browser_profiles.create_browser_profile(\n name=\"My Profile\",\n browser_session_id=\"pbs_123\",\n)\nprint(browser_profile)\n\n# Or create from a workflow run with persist_browser_session=True\nbrowser_profile = await skyvern.browser_profiles.create_browser_profile(\n name=\"My Profile\",\n workflow_run_id=\"wr_123\",\n)\nprint(browser_profile)\n" + "code": "from skyvern import Skyvern\n\nskyvern = Skyvern(api_key=\"YOUR_API_KEY\")\n# Create a blank browser profile for future runs\nblank_profile = await skyvern.browser_profiles.create_browser_profile(\n name=\"Fresh Profile\",\n)\nprint(blank_profile)\n\n# Create a browser profile from a persistent browser session\nbrowser_profile = await skyvern.browser_profiles.create_browser_profile(\n name=\"My Profile\",\n browser_session_id=\"pbs_123\",\n)\nprint(browser_profile)\n\n# Or create from a workflow run with persist_browser_session=True\nbrowser_profile = await skyvern.browser_profiles.create_browser_profile(\n name=\"My Profile\",\n workflow_run_id=\"wr_123\",\n)\nprint(browser_profile)\n" }, { "sdk": "typescript", - "code": "import { SkyvernClient } from \"@skyvern/client\";\n\nconst skyvern = new SkyvernClient({ apiKey: \"YOUR_API_KEY\" });\n// Create a browser profile from a persistent browser session\nconst browserProfile = await skyvern.browserProfiles.createBrowserProfile({\n name: \"My Profile\",\n browser_session_id: \"pbs_123\",\n});\nconsole.log(browserProfile);\n\n// Or create from a workflow run with persist_browser_session=True\nconst browserProfile2 = await skyvern.browserProfiles.createBrowserProfile({\n name: \"My Profile\",\n workflow_run_id: \"wr_123\",\n});\nconsole.log(browserProfile2);\n" + "code": "import { SkyvernClient } from \"@skyvern/client\";\n\nconst skyvern = new SkyvernClient({ apiKey: \"YOUR_API_KEY\" });\n// Create a blank browser profile for future runs\nconst blankProfile = await skyvern.browserProfiles.createBrowserProfile({\n name: \"Fresh Profile\",\n});\nconsole.log(blankProfile);\n\n// Create a browser profile from a persistent browser session\nconst browserProfile = await skyvern.browserProfiles.createBrowserProfile({\n name: \"My Profile\",\n browser_session_id: \"pbs_123\",\n});\nconsole.log(browserProfile);\n\n// Or create from a workflow run with persist_browser_session=True\nconst browserProfile2 = await skyvern.browserProfiles.createBrowserProfile({\n name: \"My Profile\",\n workflow_run_id: \"wr_123\",\n});\nconsole.log(browserProfile2);\n" } ] } @@ -2381,6 +2683,24 @@ }, "description": "Case-insensitive substring search across: browser profile name and description. A profile is returned if either field matches." }, + { + "name": "managed", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Omit to return all profiles; false returns only user-created profiles; true returns only auto-managed profiles.", + "title": "Managed" + }, + "description": "Omit to return all profiles; false returns only user-created profiles; true returns only auto-managed profiles." + }, { "name": "x-api-key", "in": "header", @@ -2731,7 +3051,8 @@ "schema": { "$ref": "#/components/schemas/CreateBrowserSessionRequest", "default": { - "timeout": 60 + "timeout": 60, + "generate_browser_profile": false } } } @@ -2937,6 +3258,90 @@ } }, "/v1/browser_sessions/{browser_session_id}": { + "patch": { + "tags": [ + "Browser Sessions" + ], + "summary": "Update a session", + "description": "Update a live browser session. Currently supports toggling generate_browser_profile, which is read when the session ends to decide whether to save its browser profile.", + "operationId": "update_browser_session_v1_browser_sessions__browser_session_id__patch", + "parameters": [ + { + "name": "browser_session_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The ID of the browser session. browser_session_id starts with `pbs_`", + "examples": [ + "pbs_123456" + ], + "title": "Browser Session Id" + }, + "description": "The ID of the browser session. browser_session_id starts with `pbs_`" + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateBrowserSessionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated browser session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserSessionResponse" + } + } + } + }, + "404": { + "description": "Browser session not found" + }, + "403": { + "description": "Unauthorized - Invalid or missing authentication" + }, + "409": { + "description": "Conflict - browser session has already ended and can no longer be updated" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-fern-sdk-method-name": "update_browser_session" + }, "get": { "tags": [ "Browser Sessions" @@ -3185,7 +3590,7 @@ }, { "sdk": "python", - "code": "from skyvern import Skyvern\n\nskyvern = Skyvern(api_key=\"YOUR_API_KEY\")\nawait skyvern.create_credential(\n name=\"My Credit Card\",\n credential_type=\"credit_card\",\n credential={\n \"card_number\": \"4242424242424242\",\n \"card_cvv\": \"424\",\n \"card_exp_month\": \"12\",\n \"card_exp_year\": \"2028\",\n \"card_brand\": \"visa\",\n \"card_holder_name\": \"John Doe\",\n },\n)\n" + "code": "from skyvern import Skyvern\n\nskyvern = Skyvern(api_key=\"YOUR_API_KEY\")\nawait skyvern.create_credential(\n name=\"My Credit Card\",\n credential_type=\"credit_card\",\n credential={\n \"card_number\": \"4242424242424242\",\n \"card_cvv\": \"424\",\n \"card_exp_month\": \"12\",\n \"card_exp_year\": \"2028\",\n \"card_brand\": \"visa\",\n \"card_holder_name\": \"John Doe\",\n \"billing_address\": {\n \"line1\": \"123 Main St\",\n \"city\": \"San Francisco\",\n \"state_code\": \"CA\",\n \"postal_code\": \"94105\",\n \"country_code\": \"US\",\n },\n \"billing_email\": \"billing@example.com\",\n \"metadata\": {\"checkout_profile\": \"default\"},\n },\n)\n" }, { "sdk": "typescript", @@ -3193,7 +3598,7 @@ }, { "sdk": "typescript", - "code": "import { SkyvernClient } from \"@skyvern/client\";\n\nconst skyvern = new SkyvernClient({ apiKey: \"YOUR_API_KEY\" });\nawait skyvern.createCredential({\n name: \"My Credit Card\",\n credential_type: \"credit_card\",\n credential: {\n card_number: \"4242424242424242\",\n card_cvv: \"424\",\n card_exp_month: \"12\",\n card_exp_year: \"2028\",\n card_brand: \"visa\",\n card_holder_name: \"John Doe\"\n }\n});\n" + "code": "import { SkyvernClient } from \"@skyvern/client\";\n\nconst skyvern = new SkyvernClient({ apiKey: \"YOUR_API_KEY\" });\nawait skyvern.createCredential({\n name: \"My Credit Card\",\n credential_type: \"credit_card\",\n credential: {\n card_number: \"4242424242424242\",\n card_cvv: \"424\",\n card_exp_month: \"12\",\n card_exp_year: \"2028\",\n card_brand: \"visa\",\n card_holder_name: \"John Doe\",\n billing_address: {\n line1: \"123 Main St\",\n city: \"San Francisco\",\n state_code: \"CA\",\n postal_code: \"94105\",\n country_code: \"US\",\n },\n billing_email: \"billing@example.com\",\n metadata: { checkout_profile: \"default\" }\n }\n});\n" } ] } @@ -3258,10 +3663,10 @@ "type": "null" } ], - "description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')", + "description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')", "title": "Vault Type" }, - "description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')" + "description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')" }, { "name": "credential_type", @@ -3631,6 +4036,256 @@ ] } }, + "/v1/custom-llms": { + "get": { + "tags": [ + "Custom LLMs" + ], + "summary": "List Custom LLMs", + "description": "List the custom LLM configurations available to the current organization.", + "operationId": "list_custom_llms_v1_custom_llms_get", + "parameters": [ + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomLLMListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-fern-sdk-method-name": "list_custom_llms" + }, + "post": { + "tags": [ + "Custom LLMs" + ], + "summary": "Create Custom LLM", + "description": "Create a custom LLM configuration for the current organization.", + "operationId": "create_custom_llm_v1_custom_llms_post", + "parameters": [ + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomLLMCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomLLMResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-fern-sdk-method-name": "create_custom_llm" + } + }, + "/v1/custom-llms/{custom_llm_id}": { + "put": { + "tags": [ + "Custom LLMs" + ], + "summary": "Update Custom LLM", + "description": "Update a custom LLM configuration for the current organization.", + "operationId": "update_custom_llm_v1_custom_llms__custom_llm_id__put", + "parameters": [ + { + "name": "custom_llm_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The custom LLM id.", + "title": "Custom Llm Id" + }, + "description": "The custom LLM id." + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomLLMUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomLLMResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-fern-sdk-method-name": "update_custom_llm" + }, + "delete": { + "tags": [ + "Custom LLMs" + ], + "summary": "Delete Custom LLM", + "description": "Delete a custom LLM configuration for the current organization.", + "operationId": "delete_custom_llm_v1_custom_llms__custom_llm_id__delete", + "parameters": [ + { + "name": "custom_llm_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The custom LLM id.", + "title": "Custom Llm Id" + }, + "description": "The custom LLM id." + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClearOrganizationAuthTokenResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-fern-sdk-method-name": "delete_custom_llm" + } + }, "/v1/run/tasks/login": { "post": { "tags": [ @@ -4478,6 +5133,24 @@ "title": "Workflow Permanent Id" }, "description": "Workflow permanent ID" + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." } ], "requestBody": { @@ -4615,6 +5288,24 @@ "title": "Key" }, "description": "Tag key to delete" + }, + { + "name": "x-api-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.", + "title": "X-Api-Key" + }, + "description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings." } ], "responses": { @@ -5136,6 +5827,44 @@ }, "description": "Exact-match filter on the error_code field inside each task's errors JSON array. A run matches if any of its tasks contains an error with a matching error_code. Error codes are user-defined strings set during workflow execution." }, + { + "name": "created_at_start", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Only include runs created at or after this UTC timestamp (ISO 8601).", + "title": "Created At Start" + }, + "description": "Only include runs created at or after this UTC timestamp (ISO 8601)." + }, + { + "name": "created_at_end", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Only include runs created strictly before this UTC timestamp (ISO 8601).", + "title": "Created At End" + }, + "description": "Only include runs created strictly before this UTC timestamp (ISO 8601)." + }, { "name": "x-api-key", "in": "header", @@ -7038,6 +7767,8 @@ "complete", "reload_page", "close_page", + "new_tab", + "switch_tab", "extract", "verification_code", "goto_url", @@ -7249,6 +7980,7 @@ "type": "string", "enum": [ "recording", + "audio", "session_replay", "browser_console_log", "skyvern_log", @@ -8020,7 +8752,8 @@ "print_page", "workflow_trigger", "google_sheets_read", - "google_sheets_write" + "google_sheets_write", + "pdf_fill" ], "title": "BlockType" }, @@ -8213,6 +8946,62 @@ ], "title": "Source Browser Type" }, + "proxy_location": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProxyLocation" + }, + { + "$ref": "#/components/schemas/GeoTarget" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Proxy Location" + }, + "proxy_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Proxy Session Id" + }, + "is_managed": { + "type": "boolean", + "title": "Is Managed", + "default": false + }, + "workflow_permanent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Permanent Id" + }, + "browser_profile_key_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Browser Profile Key Digest" + }, "created_at": { "type": "string", "format": "date-time", @@ -8397,6 +9186,12 @@ "title": "Browser Profile Id", "description": "ID of the browser profile loaded into this session, if any. browser_profile_id starts with `bp_`." }, + "generate_browser_profile": { + "type": "boolean", + "title": "Generate Browser Profile", + "description": "Whether this session's browser profile will be saved when it ends so it can become a reusable browser profile.", + "default": false + }, "vnc_streaming_supported": { "type": "boolean", "title": "Vnc Streaming Supported", @@ -8551,6 +9346,21 @@ ], "title": "BulkCancelRunsResponse" }, + "ClearOrganizationAuthTokenResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "description": "Whether the token was cleared successfully" + } + }, + "type": "object", + "required": [ + "success" + ], + "title": "ClearOrganizationAuthTokenResponse", + "description": "Response model for clearing an organization auth token." + }, "ClickAction": { "properties": { "type": { @@ -8754,6 +9564,31 @@ "type": "array", "title": "Parameters", "default": [] + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prompt" + }, + "steps": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/CodeBlockStep" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Steps" } }, "type": "object", @@ -8764,6 +9599,115 @@ ], "title": "CodeBlock" }, + "CodeBlockStep": { + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "action_type": { + "$ref": "#/components/schemas/ActionType", + "default": "null_action" + }, + "line_start": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Line Start" + }, + "line_end": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Line End" + } + }, + "type": "object", + "title": "CodeBlockStep" + }, + "CodeBlockStepYAML": { + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "action_type": { + "type": "string", + "title": "Action Type", + "default": "null_action" + }, + "line_start": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Line Start" + }, + "line_end": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Line End" + } + }, + "type": "object", + "title": "CodeBlockStepYAML" + }, "CodeBlockYAML": { "properties": { "block_type": { @@ -8833,6 +9777,31 @@ } ], "title": "Parameter Keys" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prompt" + }, + "steps": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/CodeBlockStepYAML" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Steps" } }, "type": "object", @@ -9139,16 +10108,49 @@ "browser_session_id": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1 }, { "type": "null" } ], "title": "Browser Session Id", - "description": "Persistent browser session to convert into a profile" + "description": "Persistent browser session to convert into a profile. Omit for a blank profile." }, "workflow_run_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Workflow Run Id", + "description": "Workflow run whose persisted session should be captured. Omit for a blank profile." + }, + "proxy_location": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProxyLocation" + }, + { + "$ref": "#/components/schemas/GeoTarget" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Proxy Location", + "description": "Optional proxy location for this profile's pinned proxy identity." + }, + "proxy_session_id": { "anyOf": [ { "type": "string" @@ -9157,8 +10159,8 @@ "type": "null" } ], - "title": "Workflow Run Id", - "description": "Workflow run whose persisted session should be captured" + "title": "Proxy Session Id", + "description": "Explicit sticky-session id for this profile's pinned proxy identity." } }, "type": "object", @@ -9203,6 +10205,18 @@ "title": "Proxy Location", "description": "\nGeographic Proxy location to route the browser traffic through. This is only available in Skyvern Cloud.\n\nAvailable geotargeting options:\n- RESIDENTIAL: the default value. Skyvern Cloud uses a random US residential proxy.\n- RESIDENTIAL_ES: Spain\n- RESIDENTIAL_IE: Ireland\n- RESIDENTIAL_GB: United Kingdom\n- RESIDENTIAL_IN: India\n- RESIDENTIAL_JP: Japan\n- RESIDENTIAL_FR: France\n- RESIDENTIAL_DE: Germany\n- RESIDENTIAL_NZ: New Zealand\n- RESIDENTIAL_PH: Philippines\n- RESIDENTIAL_KR: South Korea\n- RESIDENTIAL_SA: Saudi Arabia\n- RESIDENTIAL_ZA: South Africa\n- RESIDENTIAL_AR: Argentina\n- RESIDENTIAL_AU: Australia\n- RESIDENTIAL_BR: Brazil\n- RESIDENTIAL_TR: Turkey\n- RESIDENTIAL_CA: Canada\n- RESIDENTIAL_MX: Mexico\n- RESIDENTIAL_IT: Italy\n- RESIDENTIAL_NL: Netherlands\n- RESIDENTIAL_ISP: ISP proxy\n- US-CA: California (deprecated, routes through RESIDENTIAL_ISP)\n- US-NY: New York (deprecated, routes through RESIDENTIAL_ISP)\n- US-TX: Texas (deprecated, routes through RESIDENTIAL_ISP)\n- US-FL: Florida (deprecated, routes through RESIDENTIAL_ISP)\n- US-WA: Washington (deprecated, routes through RESIDENTIAL_ISP)\n- NONE: No proxy\n\nFor self-hosted deployments, you can pass a custom proxy URL as a dict: {\"url\": \"http://user:password@proxy.example.com:8080\"}. This routes the browser through your own proxy server and takes precedence over any globally configured proxy pool.\n Can also be a GeoTarget object for granular city/state targeting: {\"country\": \"US\", \"subdivision\": \"CA\", \"city\": \"San Francisco\"}, or a custom proxy URL dict for self-hosted deployments: {\"url\": \"http://user:password@proxy.example.com:8080\"}" }, + "proxy_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Proxy Session Id", + "description": "Opaque Skyvern-managed proxy sticky-session id for pinned Residential ISP sessions." + }, "extensions": { "anyOf": [ { @@ -9241,6 +10255,12 @@ ], "title": "Browser Profile Id", "description": "ID of a browser profile to load into this session (restores cookies, localStorage, etc.). browser_profile_id starts with `bp_`." + }, + "generate_browser_profile": { + "type": "boolean", + "title": "Generate Browser Profile", + "description": "When true, the session's browser profile (cookies, localStorage, etc.) is saved to storage when the session ends so it can be turned into a reusable browser profile. Defaults to false to avoid storing profiles for sessions that never need them. Sessions started with a browser_profile_id always persist their profile regardless of this flag.", + "default": false } }, "type": "object", @@ -9292,10 +10312,48 @@ ], "description": "Which vault to store this credential in. If omitted, uses the instance default. Use this to mix Skyvern-hosted and custom credentials within the same organization.", "examples": [ + "skyvern", "custom", "azure_vault", "bitwarden" ] + }, + "proxy_location": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProxyLocation" + }, + { + "$ref": "#/components/schemas/GeoTarget" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Proxy Location", + "description": "Optional proxy location for this credential's pinned proxy identity." + }, + "proxy_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Proxy Session Id", + "description": "Optional advanced reuse key for this credential's pinned proxy identity." + }, + "rotate_proxy_session_id": { + "type": "boolean", + "title": "Rotate Proxy Session Id", + "description": "Rotate the Skyvern-managed proxy sticky-session id when updating this credential.", + "default": false } }, "type": "object", @@ -9461,6 +10519,31 @@ "type": "string", "title": "Credential Id" }, + "credential_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Credential Ids" + }, + "selection_strategy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Selection Strategy" + }, "created_at": { "type": "string", "format": "date-time", @@ -9521,6 +10604,31 @@ "credential_id": { "type": "string", "title": "Credential Id" + }, + "credential_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Credential Ids" + }, + "selection_strategy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Selection Strategy" } }, "type": "object", @@ -9576,7 +10684,7 @@ "type": "null" } ], - "description": "Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')" + "description": "Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')" }, "browser_profile_id": { "anyOf": [ @@ -9640,6 +10748,37 @@ "examples": [ "cfld_1234567890" ] + }, + "proxy_location": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProxyLocation" + }, + { + "$ref": "#/components/schemas/GeoTarget" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Proxy Location", + "description": "Optional proxy location used for the credential's pinned proxy identity." + }, + "proxy_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Proxy Session Id", + "description": "Opaque Skyvern-managed proxy sticky-session id." } }, "type": "object", @@ -9665,12 +10804,141 @@ "CredentialVaultType": { "type": "string", "enum": [ + "skyvern", "bitwarden", "azure_vault", + "gcp", "custom" ], "title": "CredentialVaultType" }, + "CreditCardBillingAddress": { + "properties": { + "line1": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Line1", + "description": "Billing address line 1", + "examples": [ + "123 Main St" + ] + }, + "line2": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Line2", + "description": "Billing address line 2", + "examples": [ + "Apt 4B" + ] + }, + "city": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "City", + "description": "Billing city", + "examples": [ + "San Francisco" + ] + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State", + "description": "Billing state or region", + "examples": [ + "California" + ] + }, + "state_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State Code", + "description": "Billing state or region code", + "examples": [ + "CA" + ] + }, + "postal_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Postal Code", + "description": "Billing postal code", + "examples": [ + "94105" + ] + }, + "country": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country", + "description": "Billing country", + "examples": [ + "United States" + ] + }, + "country_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Code", + "description": "ISO 3166-1 alpha-2 billing country code", + "examples": [ + "US" + ] + } + }, + "type": "object", + "title": "CreditCardBillingAddress", + "description": "Optional billing address fields associated with a credit card credential." + }, "CreditCardCredentialResponse": { "properties": { "last_four": { @@ -9696,7 +10964,215 @@ "brand" ], "title": "CreditCardCredentialResponse", - "description": "Response model for credit card credentials — non-sensitive fields only.\n\nSECURITY: Must NEVER include full card number, CVV, expiration date, or card holder name." + "description": "Response model for credit card credentials — non-sensitive fields only.\n\nSECURITY: Must NEVER include full card number, CVV, expiration date, card holder name,\nbilling fields, or metadata." + }, + "CustomLLM": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "organization_id": { + "type": "string", + "title": "Organization Id" + }, + "config": { + "$ref": "#/components/schemas/CustomLLMConfig" + }, + "created_at": { + "type": "string", + "title": "Created At" + }, + "modified_at": { + "type": "string", + "title": "Modified At" + }, + "valid": { + "type": "boolean", + "title": "Valid" + } + }, + "type": "object", + "required": [ + "id", + "organization_id", + "config", + "created_at", + "modified_at", + "valid" + ], + "title": "CustomLLM" + }, + "CustomLLMConfig": { + "properties": { + "display_name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Display Name" + }, + "provider": { + "$ref": "#/components/schemas/CustomLLMProvider" + }, + "model_name": { + "type": "string", + "maxLength": 250, + "minLength": 1, + "title": "Model Name" + }, + "api_base": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "title": "Api Base" + }, + "api_key": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "title": "Api Key" + }, + "api_version": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Api Version" + }, + "supports_vision": { + "type": "boolean", + "title": "Supports Vision", + "default": true + }, + "add_assistant_prefix": { + "type": "boolean", + "title": "Add Assistant Prefix", + "default": false + }, + "max_completion_tokens": { + "anyOf": [ + { + "type": "integer", + "maximum": 1000000.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Max Completion Tokens" + }, + "temperature": { + "anyOf": [ + { + "type": "number", + "maximum": 2.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Temperature" + }, + "reasoning_effort": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "title": "Reasoning Effort" + } + }, + "type": "object", + "required": [ + "display_name", + "provider", + "model_name" + ], + "title": "CustomLLMConfig" + }, + "CustomLLMCreateRequest": { + "properties": { + "config": { + "$ref": "#/components/schemas/CustomLLMConfig" + } + }, + "type": "object", + "required": [ + "config" + ], + "title": "CustomLLMCreateRequest" + }, + "CustomLLMListResponse": { + "properties": { + "custom_llms": { + "items": { + "$ref": "#/components/schemas/CustomLLM" + }, + "type": "array", + "title": "Custom Llms" + } + }, + "type": "object", + "required": [ + "custom_llms" + ], + "title": "CustomLLMListResponse" + }, + "CustomLLMProvider": { + "type": "string", + "enum": [ + "openai_compatible", + "ollama", + "openrouter" + ], + "title": "CustomLLMProvider" + }, + "CustomLLMResponse": { + "properties": { + "custom_llm": { + "$ref": "#/components/schemas/CustomLLM" + } + }, + "type": "object", + "required": [ + "custom_llm" + ], + "title": "CustomLLMResponse" + }, + "CustomLLMUpdateRequest": { + "properties": { + "config": { + "$ref": "#/components/schemas/CustomLLMConfig" + } + }, + "type": "object", + "required": [ + "config" + ], + "title": "CustomLLMUpdateRequest" }, "DeleteScheduleResponse": { "properties": { @@ -11414,7 +12890,8 @@ "type": "string", "enum": [ "s3", - "azure" + "azure", + "google_drive" ], "title": "FileStorageType" }, @@ -11426,7 +12903,8 @@ "excel", "pdf", "image", - "docx" + "docx", + "zip" ], "title": "FileType" }, @@ -11571,6 +13049,28 @@ ], "title": "Azure Blob Container Name" }, + "google_credential_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Google Credential Id" + }, + "google_drive_folder_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Google Drive Folder Id" + }, "path": { "anyOf": [ { @@ -11581,6 +13081,12 @@ } ], "title": "Path" + }, + "continue_on_empty": { + "type": "boolean", + "title": "Continue On Empty", + "description": "When the run download directory has no files, allow the empty upload only after confirming no registered, browser-session, or alternate candidate downloads exist (False, default). Set True to always allow an empty upload.", + "default": false } }, "type": "object", @@ -11734,6 +13240,28 @@ ], "title": "Azure Folder Path" }, + "google_credential_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Google Credential Id" + }, + "google_drive_folder_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Google Drive Folder Id" + }, "path": { "anyOf": [ { @@ -12014,6 +13542,9 @@ }, { "$ref": "#/components/schemas/GoogleSheetsWriteBlock" + }, + { + "$ref": "#/components/schemas/PdfFillBlock" } ], "discriminator": { @@ -12035,6 +13566,7 @@ "human_interaction": "#/components/schemas/HumanInteractionBlock", "login": "#/components/schemas/LoginBlock", "navigation": "#/components/schemas/NavigationBlock", + "pdf_fill": "#/components/schemas/PdfFillBlock", "pdf_parser": "#/components/schemas/PDFParserBlock", "print_page": "#/components/schemas/PrintPageBlock", "send_email": "#/components/schemas/SendEmailBlock", @@ -12280,6 +13812,9 @@ { "$ref": "#/components/schemas/PrintPageBlockYAML" }, + { + "$ref": "#/components/schemas/PdfFillBlockYAML" + }, { "$ref": "#/components/schemas/WorkflowTriggerBlockYAML" }, @@ -13180,6 +14715,20 @@ "title": "Save Response As File", "default": false }, + "secret_response_paths": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Secret Response Paths" + }, "parameters": { "items": { "oneOf": [ @@ -13380,6 +14929,20 @@ "title": "Save Response As File", "default": false }, + "secret_response_paths": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Secret Response Paths" + }, "parameter_keys": { "anyOf": [ { @@ -15436,6 +16999,56 @@ "examples": [ "John Doe" ] + }, + "billing_address": { + "anyOf": [ + { + "$ref": "#/components/schemas/CreditCardBillingAddress" + }, + { + "type": "null" + } + ], + "description": "Optional billing address associated with the card" + }, + "billing_email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Billing Email", + "description": "Optional billing email address" + }, + "billing_phone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Billing Phone", + "description": "Optional billing phone number" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata", + "description": "Optional additional credit card metadata fields" } }, "type": "object", @@ -16081,6 +17694,285 @@ "title": "PasswordCredentialResponse", "description": "Response model for password credentials — non-sensitive fields only.\n\nSECURITY: Must NEVER include password or TOTP secret." }, + "PdfFillBlock": { + "properties": { + "label": { + "type": "string", + "title": "Label", + "description": "Author-facing identifier for a block; unique within a workflow." + }, + "next_block_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Block Label", + "description": "Optional pointer to the next block label when constructing a DAG. Defaults to sequential order when omitted." + }, + "block_type": { + "type": "string", + "const": "pdf_fill", + "title": "Block Type", + "default": "pdf_fill" + }, + "output_parameter": { + "$ref": "#/components/schemas/OutputParameter" + }, + "continue_on_failure": { + "type": "boolean", + "title": "Continue On Failure", + "default": false + }, + "model": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "disable_cache": { + "type": "boolean", + "title": "Disable Cache", + "default": false + }, + "ignore_workflow_system_prompt": { + "type": "boolean", + "title": "Ignore Workflow System Prompt", + "default": false + }, + "next_loop_on_failure": { + "type": "boolean", + "title": "Next Loop On Failure", + "default": false + }, + "file_url": { + "type": "string", + "title": "File Url" + }, + "prompt": { + "type": "string", + "title": "Prompt" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "items": {}, + "type": "array" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Payload" + }, + "llm_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Llm Key" + }, + "parameters": { + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/WorkflowParameter" + }, + { + "$ref": "#/components/schemas/ContextParameter" + }, + { + "$ref": "#/components/schemas/AWSSecretParameter" + }, + { + "$ref": "#/components/schemas/AzureSecretParameter" + }, + { + "$ref": "#/components/schemas/BitwardenLoginCredentialParameter" + }, + { + "$ref": "#/components/schemas/BitwardenSensitiveInformationParameter" + }, + { + "$ref": "#/components/schemas/BitwardenCreditCardDataParameter" + }, + { + "$ref": "#/components/schemas/OnePasswordCredentialParameter" + }, + { + "$ref": "#/components/schemas/AzureVaultCredentialParameter" + }, + { + "$ref": "#/components/schemas/OutputParameter" + }, + { + "$ref": "#/components/schemas/CredentialParameter" + } + ], + "discriminator": { + "propertyName": "parameter_type", + "mapping": { + "aws_secret": "#/components/schemas/AWSSecretParameter", + "azure_secret": "#/components/schemas/AzureSecretParameter", + "azure_vault_credential": "#/components/schemas/AzureVaultCredentialParameter", + "bitwarden_credit_card_data": "#/components/schemas/BitwardenCreditCardDataParameter", + "bitwarden_login_credential": "#/components/schemas/BitwardenLoginCredentialParameter", + "bitwarden_sensitive_information": "#/components/schemas/BitwardenSensitiveInformationParameter", + "context": "#/components/schemas/ContextParameter", + "credential": "#/components/schemas/CredentialParameter", + "onepassword": "#/components/schemas/OnePasswordCredentialParameter", + "output": "#/components/schemas/OutputParameter", + "workflow": "#/components/schemas/WorkflowParameter" + } + } + }, + "type": "array", + "title": "Parameters", + "default": [] + } + }, + "type": "object", + "required": [ + "label", + "output_parameter", + "file_url", + "prompt" + ], + "title": "PdfFillBlock" + }, + "PdfFillBlockYAML": { + "properties": { + "block_type": { + "type": "string", + "const": "pdf_fill", + "title": "Block Type", + "default": "pdf_fill" + }, + "label": { + "type": "string", + "title": "Label", + "description": "Author-facing identifier; must be unique per workflow." + }, + "next_block_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Block Label", + "description": "Optional pointer to the label of the next block. When omitted, it will default to sequential order. See [[s-4bnl]]." + }, + "continue_on_failure": { + "type": "boolean", + "title": "Continue On Failure", + "default": false + }, + "model": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "ignore_workflow_system_prompt": { + "type": "boolean", + "title": "Ignore Workflow System Prompt", + "default": false + }, + "next_loop_on_failure": { + "type": "boolean", + "title": "Next Loop On Failure", + "default": false + }, + "file_url": { + "type": "string", + "title": "File Url" + }, + "prompt": { + "type": "string", + "title": "Prompt" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "items": {}, + "type": "array" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Payload" + }, + "llm_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Llm Key" + }, + "parameter_keys": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Parameter Keys" + } + }, + "type": "object", + "required": [ + "label", + "file_url", + "prompt" + ], + "title": "PdfFillBlockYAML" + }, "PersistentBrowserType": { "type": "string", "enum": [ @@ -17571,6 +19463,21 @@ "type": "array", "title": "Tags To Delete", "description": "Tags to soft-delete. List of {key?, value?} targets." + }, + "colors": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Colors", + "description": "Optional map of grouped tag key to palette color name for the value being set. Keys absent from this map keep their existing color or receive a random palette color." } }, "type": "object", @@ -17861,6 +19768,146 @@ "title": "TagResponse", "description": "Current state of one tag (``GET /v1/workflows/{wpid}/tags`` row).\n``key`` is null for a standalone label." }, + "TagValue": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "value": { + "type": "string", + "title": "Value" + }, + "color": { + "type": "string", + "title": "Color" + }, + "workflow_count": { + "type": "integer", + "title": "Workflow Count", + "description": "Number of non-deleted workflows currently carrying this (key, value) label.", + "default": 0 + } + }, + "type": "object", + "required": [ + "key", + "value", + "color" + ], + "title": "TagValue", + "description": "Tag-value color registry entry: the palette color assigned to a grouped\n(key, value) pair." + }, + "TagValueDelete": { + "properties": { + "value": { + "type": "string", + "title": "Value", + "description": "Tag value (label) under the key to soft-delete." + } + }, + "type": "object", + "required": [ + "value" + ], + "title": "TagValueDelete", + "description": "Body for ``DELETE /v1/tag-values/{key}``. The value rides in the body, not the\npath, so values containing ``/`` stay addressable." + }, + "TagValueDeleteResponse": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "value": { + "type": "string", + "title": "Value" + }, + "removed_from_workflow_count": { + "type": "integer", + "title": "Removed From Workflow Count" + } + }, + "type": "object", + "required": [ + "key", + "value", + "removed_from_workflow_count" + ], + "title": "TagValueDeleteResponse", + "description": "Response for ``DELETE /v1/tag-values/{key}``." + }, + "TagValueRename": { + "properties": { + "value": { + "type": "string", + "title": "Value", + "description": "Current tag value (label) under the key to rename." + }, + "new_value": { + "type": "string", + "title": "New Value", + "description": "New tag value (label) to rename it to." + } + }, + "type": "object", + "required": [ + "value", + "new_value" + ], + "title": "TagValueRename", + "description": "Body for ``PATCH /v1/tag-values/{key}/rename``. Both the current and the new\nvalue ride in the body so values containing ``/`` stay addressable." + }, + "TagValueRenameResponse": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "value": { + "type": "string", + "title": "Value" + }, + "color": { + "type": "string", + "title": "Color" + }, + "renamed_workflow_count": { + "type": "integer", + "title": "Renamed Workflow Count" + } + }, + "type": "object", + "required": [ + "key", + "value", + "color", + "renamed_workflow_count" + ], + "title": "TagValueRenameResponse", + "description": "Response for ``PATCH /v1/tag-values/{key}/rename``: the renamed label with its\ncarried-over color and the number of workflows re-tagged." + }, + "TagValueUpdate": { + "properties": { + "value": { + "type": "string", + "title": "Value", + "description": "Tag value (label) under the key to recolor." + }, + "color": { + "type": "string", + "title": "Color", + "description": "Palette color name to assign to this (key, value)." + } + }, + "type": "object", + "required": [ + "value", + "color" + ], + "title": "TagValueUpdate", + "description": "Body for ``PATCH /v1/tag-values/{key}``. The value rides in the body, not the\npath, so values containing ``/`` stay addressable." + }, "TagsResponse": { "properties": { "workflow_permanent_id": { @@ -19520,7 +21567,7 @@ "max_iterations": { "type": "integer", "title": "Max Iterations", - "default": 10 + "default": 50 }, "max_steps": { "type": "integer", @@ -19628,7 +21675,7 @@ "max_iterations": { "type": "integer", "title": "Max Iterations", - "default": 10 + "default": 50 }, "max_steps": { "type": "integer", @@ -20191,11 +22238,62 @@ ], "title": "Description", "description": "New description for the browser profile" + }, + "proxy_location": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProxyLocation" + }, + { + "$ref": "#/components/schemas/GeoTarget" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Proxy Location", + "description": "Optional proxy location for this profile's pinned proxy identity." + }, + "proxy_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Proxy Session Id", + "description": "Opaque Skyvern-managed proxy sticky-session id." + }, + "rotate_proxy_session_id": { + "type": "boolean", + "title": "Rotate Proxy Session Id", + "description": "Rotate the Skyvern-managed proxy sticky-session id for this profile.", + "default": false } }, "type": "object", "title": "UpdateBrowserProfileRequest" }, + "UpdateBrowserSessionRequest": { + "properties": { + "generate_browser_profile": { + "type": "boolean", + "title": "Generate Browser Profile", + "description": "Enable or disable saving this session's browser profile when it ends. Can be toggled while the session is still alive; the value is read at session teardown." + } + }, + "type": "object", + "required": [ + "generate_browser_profile" + ], + "title": "UpdateBrowserSessionRequest" + }, "UpdateWorkflowFolderRequest": { "properties": { "folder_id": { @@ -21682,6 +23780,9 @@ }, { "$ref": "#/components/schemas/GoogleSheetsWriteBlock" + }, + { + "$ref": "#/components/schemas/PdfFillBlock" } ], "discriminator": { @@ -21703,6 +23804,7 @@ "human_interaction": "#/components/schemas/HumanInteractionBlock", "login": "#/components/schemas/LoginBlock", "navigation": "#/components/schemas/NavigationBlock", + "pdf_fill": "#/components/schemas/PdfFillBlock", "pdf_parser": "#/components/schemas/PDFParserBlock", "print_page": "#/components/schemas/PrintPageBlock", "send_email": "#/components/schemas/SendEmailBlock", @@ -21876,6 +23978,9 @@ { "$ref": "#/components/schemas/PrintPageBlockYAML" }, + { + "$ref": "#/components/schemas/PdfFillBlockYAML" + }, { "$ref": "#/components/schemas/WorkflowTriggerBlockYAML" }, @@ -22003,6 +24108,11 @@ "title": "Persist Browser Session", "default": false }, + "pin_saved_session_ip": { + "type": "boolean", + "title": "Pin Saved Session Ip", + "default": false + }, "browser_profile_id": { "anyOf": [ { @@ -22014,6 +24124,17 @@ ], "title": "Browser Profile Id" }, + "browser_profile_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Browser Profile Key" + }, "model": { "anyOf": [ { @@ -22106,6 +24227,11 @@ "title": "Adaptive Caching", "default": false }, + "enable_self_healing": { + "type": "boolean", + "title": "Enable Self Healing", + "default": false + }, "code_version": { "anyOf": [ { @@ -22188,6 +24314,11 @@ ], "title": "Edited By" }, + "copilot_authored": { + "type": "boolean", + "title": "Copilot Authored", + "default": false + }, "created_at": { "type": "string", "format": "date-time", @@ -22305,6 +24436,11 @@ "title": "Persist Browser Session", "default": false }, + "pin_saved_session_ip": { + "type": "boolean", + "title": "Pin Saved Session Ip", + "default": false + }, "browser_profile_id": { "anyOf": [ { @@ -22316,6 +24452,17 @@ ], "title": "Browser Profile Id" }, + "browser_profile_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Browser Profile Key" + }, "model": { "anyOf": [ { @@ -22351,7 +24498,7 @@ "anyOf": [ { "type": "integer", - "maximum": 240.0, + "maximum": 480.0, "minimum": 1.0 }, { @@ -22419,6 +24566,17 @@ "title": "Adaptive Caching", "default": false }, + "enable_self_healing": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Self Healing" + }, "code_version": { "anyOf": [ { @@ -22619,6 +24777,9 @@ }, { "$ref": "#/components/schemas/GoogleSheetsWriteBlock" + }, + { + "$ref": "#/components/schemas/PdfFillBlock" } ], "discriminator": { @@ -22640,6 +24801,7 @@ "human_interaction": "#/components/schemas/HumanInteractionBlock", "login": "#/components/schemas/LoginBlock", "navigation": "#/components/schemas/NavigationBlock", + "pdf_fill": "#/components/schemas/PdfFillBlock", "pdf_parser": "#/components/schemas/PDFParserBlock", "print_page": "#/components/schemas/PrintPageBlock", "send_email": "#/components/schemas/SendEmailBlock", @@ -22704,9 +24866,15 @@ "WorkflowDefinitionYAML": { "properties": { "version": { - "type": "integer", - "title": "Version", - "default": 1 + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" }, "parameters": { "items": { @@ -22836,6 +25004,9 @@ { "$ref": "#/components/schemas/PrintPageBlockYAML" }, + { + "$ref": "#/components/schemas/PdfFillBlockYAML" + }, { "$ref": "#/components/schemas/WorkflowTriggerBlockYAML" }, @@ -22865,6 +25036,7 @@ "human_interaction": "#/components/schemas/HumanInteractionBlockYAML", "login": "#/components/schemas/LoginBlockYAML", "navigation": "#/components/schemas/NavigationBlockYAML", + "pdf_fill": "#/components/schemas/PdfFillBlockYAML", "pdf_parser": "#/components/schemas/PDFParserBlockYAML", "print_page": "#/components/schemas/PrintPageBlockYAML", "send_email": "#/components/schemas/SendEmailBlockYAML", @@ -23892,6 +26064,17 @@ ], "title": "Body" }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prompt" + }, "instructions": { "anyOf": [ { @@ -23978,6 +26161,17 @@ "type": "null" } ] + }, + "downloaded_file_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Downloaded File Count" } }, "type": "object", @@ -24130,7 +26324,7 @@ "anyOf": [ { "type": "integer", - "maximum": 240.0, + "maximum": 480.0, "minimum": 1.0 }, { @@ -24138,7 +26332,7 @@ } ], "title": "Max Elapsed Time Minutes", - "description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours." + "description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes." }, "extra_http_headers": { "anyOf": [ @@ -24375,7 +26569,7 @@ "anyOf": [ { "type": "integer", - "maximum": 240.0, + "maximum": 480.0, "minimum": 1.0 }, { @@ -24383,7 +26577,7 @@ } ], "title": "Max Elapsed Time Minutes", - "description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours." + "description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes." }, "extra_http_headers": { "anyOf": [ diff --git a/docs/changelog-emails/2026-06-june.md b/docs/changelog-emails/2026-06-june.md new file mode 100644 index 000000000..bc960d3a0 --- /dev/null +++ b/docs/changelog-emails/2026-06-june.md @@ -0,0 +1,114 @@ +![Skyvern Changelog — June 2026](images/2026-06-00-header.png) + +# Skyvern Changelog — June 2026 + +*Everything we shipped in June 2026 — weeks of June 1, 8, 15, and 22.* + +--- + +## Workflows Are Now Agents + +We renamed **Workflows to Agents** across the product, the public API, and the docs. The API now lives under **`/agents`** (e.g. `POST /v1/run/agents`) and accepts an **`agent_id`** alias for `workflow_id` — same `wpid_` value, echoed back in responses. It's fully backwards-compatible: your existing `workflow_id` inputs, `/workflows` endpoints, SDK methods, and bookmarked doc URLs all keep working. + +![Workflows are now Agents — the app renamed to Agents with the public API served under /agents and an agent_id alias for workflow_id, shown as backwards-compatible](images/2026-06-01-agents-rename.png) + +--- + +## Workflow Studio (Preview) + +Meet the next-generation editor. **Workflow Studio** is an opt-in preview that reimagines how you build: a refreshed **build canvas**, an **integrated live browser view**, and a built-in **run viewer** — all in one place. Take control of the live browser, center the viewport, and watch a run unfold without leaving the editor. Flip it on from Feature Preview in Settings. + +![Workflow Studio preview — a redesigned editor with a build canvas on the left, an integrated live browser view in the center, and a run viewer panel, with a take-control button on the live browser](images/2026-06-02-workflow-studio.png) + +--- + +## Code-First Code Blocks + +For when you want to drop to code. Code blocks now have a **dedicated code editor** with **syntax highlighting**, **Jinja parameter hints**, and a **run timeline** that records every action — including `page.evaluate(...)` calls. A **Build ↔ Code toggle** flips between visual and code editing, block labels and extraction output render correctly, and Copilot-generated code blocks support a **typed extraction schema** and fill logins with your stored credentials. + +![Code-first code block — a dedicated code editor with syntax highlighting and Jinja parameter hints, a Build/Code toggle in the header, and a run timeline listing each action taken](images/2026-06-03-code-first.png) + +--- + +## Copilot: Ask, Build, Code — With Memory + +The Copilot got sharper about intent and context. It now **routes every request by mode** — **Ask** answers without touching your workflow, **Build** creates or edits it, **Code** generates executable code blocks. It **remembers the full chat history per workflow**, so prior context and decisions carry forward as you keep building. And its **repair loop is honest now**: when consecutive fixes make no verified progress, it stops after three tries and escalates cleanly — draft preserved — instead of spinning. + +![Workflow Copilot — a chat with an Ask / Build / Code mode selector, per-workflow chat history in a side rail, and a repair loop that halts after three attempts with an honest escalation message](images/2026-06-04-copilot.png) + +--- + +## A Bigger Model Menu + +More choice for every run. New this month: **DeepSeek**, **Xiaomi MiMo** (and **MiMo-V2.5** via OpenRouter), **Gemini 3.5 Flash**, **GPT-5-mini** (your own OpenAI key or via OpenRouter), and **Claude Opus 4.8** (Anthropic + Bedrock). OpenRouter runs now track token counts and cost like every other provider. (Gemini 2.5 Pro/Flash are deprecated; CUA and the Claude Opus family are now enterprise-only.) + +![Model dropdown — an expanded model picker listing DeepSeek, Xiaomi MiMo, Gemini 3.5 Flash, GPT-5-mini, and Claude Opus 4.8, with an OpenRouter cost-tracking badge](images/2026-06-05-models.png) + +--- + +## Credentials & OTP, Everywhere + +Logins that just work. Pull credentials straight from **1Password** (with an item picker) or **Bitwarden**, look up one-time passcodes from **Gmail** via OAuth, and **fill OTP at runtime** from your stored TOTP secrets — even inside code blocks. Expired credential sessions **auto re-save**, and you can **clear a saved credential** without deleting and recreating it. + +![Credentials setup — a credential picker offering 1Password and Bitwarden vault items, a Gmail-backed OTP lookup option, and a runtime OTP fill using a stored TOTP secret](images/2026-06-06-credentials.png) + +--- + +## Multi-Tab Browser Control + +Automations that span tabs. The autonomous agent loop can now **open and work across multiple browser tabs** at once — following new-tab links and handling pop-ups — so flows that jump between tabs no longer dead-end. + +![Multi-tab browser control — an agent working across two browser tabs at once, following a link that opened a second tab and acting in both](images/2026-06-07-multi-tab.png) + +--- + +## Self-Host on Google Cloud + +Full GCP-native deployments. Self-hosted Skyvern now supports **Google Cloud Storage** for artifacts and **GCP Secret Manager** as a vault backend — so you can run the whole stack on Google Cloud with your own storage and secrets. + +![Self-hosting on GCP — a deployment config showing Google Cloud Storage selected for artifacts and GCP Secret Manager selected as the vault backend](images/2026-06-08-gcp-self-host.png) + +--- + +## Quick List — June + +**New features** +- **Analytics drill-down & tag filters** — period-aware per-agent drill-down (avg duration, credits/run, period-over-period trends) plus filtering runs and metrics by workflow tag +- **Agent Directory Tree** — the agents/folders list is now a navigable tree for nested hierarchies +- **Self-serve editor onboarding** — a get-started modal, a 4-stop guided tour, and smart empty states for faster time-to-first-automation +- **Bulk actions on the agents list** — select multiple agents and act on them at once +- **Label management in Settings** — rename, recolor, and soft-delete tags +- **Browser profiles in MCP sessions** — associate a browser profile with MCP-driven runs for credential/state reuse +- **Feature Preview panel in Settings** — opt into upcoming features before they're fully released +- **Workflow schedules in open source** — self-hosted deployments now support schedules +- **Uncapped workflow runtime** — configure long-running automations with no maximum time limit + +**Improvements** +- Structured output is verified against its schema before a run is marked complete +- Tasks return a best-effort **partial deliverable** when they exhaust their credit budget +- The task planner prefers **loop-based tasks** for repeated same-shaped work, and can now emit EXTRACT_INFORMATION / GOTO_URL actions +- The agent **acts immediately on stalled pages** and **halts reliably on terminal blockers** instead of spinning +- Remote browser sessions capped at 4 hours; debug sessions stay open after a run so you can inspect the final page +- End-state screenshots captured for **every open tab** at completion; per-run recordings clipped to the run window and stored as downloadable artifacts +- Run app + recording URLs surfaced in MCP and CLI responses; run-ID search fallback on the runs list +- Batched cache invalidation on workflow save (removed a per-block N+1) for faster saves on large workflows +- OpenRouter cost tracking; analytics tag filters accept labels, groups, or `group:label` +- Improved authenticator-2FA credential setup; PDF field mapping by positional labels +- Copilot data-write blocks default to `continue_on_failure=false`; code-repair progress shown as quiet indicators + +**Bug fixes** +- Fixed the workflow builder freezing on an infinite FlowRenderer layout loop +- Fixed the code editor crashing (stack overflow) on large or deeply-nested JSON +- Fixed the app failing to load after a deploy from stale chunks — it now auto-recovers with a cache-busting reload +- Fixed login credentials not reattaching after editing a workflow via MCP +- Fixed conditional routing not being preserved when loading a v1-format workflow +- Fixed the active run context being lost when switching organizations +- Fixed multi-step logins not advancing when credentials filled JavaScript-gated fields +- Fixed scanned PDFs being parsed as a single page instead of page-by-page +- Fixed shadow-DOM text nodes being missed during element text extraction +- Fixed runs not stopping when a configured time limit was exceeded +- Fixed credits not recorded when data was captured inside a Navigate block +- Fixed telephone inputs dropping digits on bare NANP-format numbers +- Fixed persistent sessions reporting a stale cached session as live +- Fixed FileUpload blocks silently skipping when no file was provided (now a clean no-op) +- Fixed the block library drawer overflowing its container in the workflow builder diff --git a/docs/changelog-emails/images/2026-06-00-header.png b/docs/changelog-emails/images/2026-06-00-header.png new file mode 100644 index 000000000..64edf83c0 Binary files /dev/null and b/docs/changelog-emails/images/2026-06-00-header.png differ diff --git a/docs/changelog-emails/images/2026-06-01-agents-rename.png b/docs/changelog-emails/images/2026-06-01-agents-rename.png new file mode 100644 index 000000000..b59997963 Binary files /dev/null and b/docs/changelog-emails/images/2026-06-01-agents-rename.png differ diff --git a/docs/changelog-emails/images/2026-06-02-workflow-studio.png b/docs/changelog-emails/images/2026-06-02-workflow-studio.png new file mode 100644 index 000000000..c5bf24849 Binary files /dev/null and b/docs/changelog-emails/images/2026-06-02-workflow-studio.png differ diff --git a/docs/changelog-emails/images/2026-06-03-code-first.png b/docs/changelog-emails/images/2026-06-03-code-first.png new file mode 100644 index 000000000..9bf2eac49 Binary files /dev/null and b/docs/changelog-emails/images/2026-06-03-code-first.png differ diff --git a/docs/changelog-emails/images/2026-06-04-copilot.png b/docs/changelog-emails/images/2026-06-04-copilot.png new file mode 100644 index 000000000..4d6c1298a Binary files /dev/null and b/docs/changelog-emails/images/2026-06-04-copilot.png differ diff --git a/docs/changelog-emails/images/2026-06-05-models.png b/docs/changelog-emails/images/2026-06-05-models.png new file mode 100644 index 000000000..8266e172b Binary files /dev/null and b/docs/changelog-emails/images/2026-06-05-models.png differ diff --git a/docs/changelog-emails/images/2026-06-06-credentials.png b/docs/changelog-emails/images/2026-06-06-credentials.png new file mode 100644 index 000000000..a50398a5d Binary files /dev/null and b/docs/changelog-emails/images/2026-06-06-credentials.png differ diff --git a/docs/changelog-emails/images/2026-06-07-multi-tab.png b/docs/changelog-emails/images/2026-06-07-multi-tab.png new file mode 100644 index 000000000..038d99f6e Binary files /dev/null and b/docs/changelog-emails/images/2026-06-07-multi-tab.png differ diff --git a/docs/changelog-emails/images/2026-06-08-gcp-self-host.png b/docs/changelog-emails/images/2026-06-08-gcp-self-host.png new file mode 100644 index 000000000..15338f88b Binary files /dev/null and b/docs/changelog-emails/images/2026-06-08-gcp-self-host.png differ diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 3a7f0bd69..343b06341 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -13,6 +13,9 @@ keywords:

TIMELINE

+ Week of Jun 30, 2026 + Week of Jun 22, 2026 + Week of Jun 15, 2026 Week of Jun 8, 2026 Week of Jun 1, 2026 Week of May 25, 2026 @@ -20,31 +23,168 @@ keywords: Week of May 11, 2026 Week of May 4, 2026 Week of Apr 27, 2026 - v1.0.24 — Apr 21, 2026 - v1.0.23 — Apr 14, 2026 - v1.0.22 — Feb 26, 2026 - v1.0.21 — Feb 24, 2026 - v1.0.20 — Feb 21, 2026 - v1.0.19 — Feb 20, 2026 - v1.0.18 — Feb 19, 2026 - v1.0.17 — Feb 19, 2026 - v1.0.16 — Feb 19, 2026 - v1.0.15 — Feb 17, 2026 - v1.0.14 — Feb 17, 2026 - v1.0.13 — Feb 10, 2026 - v1.0.12 — Feb 3, 2026 - v1.0.11 — Jan 29, 2026 - v1.0.10 — Jan 22, 2026 - v1.0.9 — Jan 20, 2026 - v1.0.8 — Jan 9, 2026 + Week of Apr 20, 2026 + Week of Apr 13, 2026 + Week of Feb 23, 2026 + Week of Feb 16, 2026 + Week of Feb 9, 2026 + Week of Feb 2, 2026 + Week of Jan 26, 2026 + Week of Jan 19, 2026 + Week of Jan 5, 2026

Changelog

New features, improvements, and fixes in Skyvern

+ + + +## Improvements + +- **Block Type Shown in Workflow Studio Header** — The block detail panel in Workflow Studio now displays the block type in the header, making it easier to identify what kind of block you're editing at a glance. ([#6956](https://github.com/Skyvern-AI/skyvern/pull/6956)) +- **Improved Extraction Accuracy for Absent Data** — The agent now better handles cases where requested output is genuinely absent from the page, reducing false positives in extraction results. ([#6957](https://github.com/Skyvern-AI/skyvern/pull/6957)) +- **Data-Only Validation Skips Page Evidence** — Validation steps that check only extracted data no longer capture page screenshots when they aren't needed, reducing unnecessary work during runs. ([#6960](https://github.com/Skyvern-AI/skyvern/pull/6960)) +- **Copilot Block Runs Routed to UI Worker** — Copilot block runs are now dispatched to a dedicated UI worker, keeping the editor responsive during live automation. ([#6962](https://github.com/Skyvern-AI/skyvern/pull/6962)) +- **Clearer Invalid JSON Errors in Workflow Editor** — The workflow editor now surfaces a descriptive error when a parameter contains invalid JSON, making it faster to diagnose and fix configuration mistakes. ([#6963](https://github.com/Skyvern-AI/skyvern/pull/6963)) +- **"Ignore System Prompt" Moved to Advanced Settings** — The "Ignore system prompt" option has been relocated to the advanced settings panel, reducing clutter in the default block configuration view. ([#6964](https://github.com/Skyvern-AI/skyvern/pull/6964)) +- **Empty Extra HTTP Headers Hidden** — Extra HTTP header fields that are empty no longer appear in block configurations, reducing visual noise in the workflow editor. ([#6966](https://github.com/Skyvern-AI/skyvern/pull/6966)) +- **Block Selection Persisted in URL** — The selected block in Workflow Studio is now encoded in the URL, so your selection is preserved on reload and can be shared via link. ([#6969](https://github.com/Skyvern-AI/skyvern/pull/6969)) +- **Code-Block Self-Heal Minimum Retry Floor** — The code-block self-healing loop now enforces a minimum number of repair attempts before giving up, preventing premature failures on recoverable errors. ([#6971](https://github.com/Skyvern-AI/skyvern/pull/6971)) +- **Faster Live-Draft Enrichment** — Workflow recording live-draft enrichment now uses a faster model and a shorter debounce interval, making draft suggestions appear noticeably sooner while recording. ([#6973](https://github.com/Skyvern-AI/skyvern/pull/6973)) + +## Bug fixes + +- Fixed syntax highlighting disappearing when viewing large webhook payloads. ([#6967](https://github.com/Skyvern-AI/skyvern/pull/6967)) +- Fixed LLM blocks not enforcing their configured output schema, which could allow malformed data to propagate to downstream blocks. ([#6965](https://github.com/Skyvern-AI/skyvern/pull/6965)) + + + + + + +## New features + +- **Gmail-Backed OTP Lookup** — Agents can now retrieve one-time passwords from Gmail via OAuth, enabling automated login flows that depend on email-delivered verification codes. ([#6769](https://github.com/Skyvern-AI/skyvern/pull/6769)) +- **Copilot Chat History per Workflow** — The Copilot now retains its full conversation history scoped to each individual workflow, so prior context and decisions are preserved as you continue building. ([#6767](https://github.com/Skyvern-AI/skyvern/pull/6767)) +- **Code-First Editor Polish** — Code-first code blocks now display block labels, correctly render extraction output, and include a Build-vs-Code toggle for switching between visual and code editing modes. ([#6771](https://github.com/Skyvern-AI/skyvern/pull/6771)) +- **Workflow Studio (Preview)** — A redesigned workflow editor with a refreshed build canvas, integrated browser view, and run viewer is now available as an opt-in preview, giving teams an early look at the next-generation authoring experience. ([#6782](https://github.com/Skyvern-AI/skyvern/pull/6782)) +- **DeepSeek and Xiaomi MiMo Models** — DeepSeek and Xiaomi MiMo are now available in the model dropdown for agent runs. Gemini 2.5 Pro and 2.5 Flash have been deprecated. ([#6831](https://github.com/Skyvern-AI/skyvern/pull/6831)) +- **Label Management Settings** — Labels can now be renamed, recolored, and soft-deleted directly from the Settings page, making it easier to maintain a clean tag taxonomy. ([#6830](https://github.com/Skyvern-AI/skyvern/pull/6830)) +- **Browser Profiles in MCP Sessions** — Browser sessions initiated via MCP can now be associated with a browser profile, enabling credential and state reuse across MCP-driven runs. ([#6798](https://github.com/Skyvern-AI/skyvern/pull/6798)) +- **Workflow-Tag Filter on Analytics** — The analytics dashboard now supports filtering runs and metrics by workflow tag. ([#6780](https://github.com/Skyvern-AI/skyvern/pull/6780)) + +## Improvements + +- **Copilot Repair Shows Quiet Progress** — The Copilot's code-seam repair steps now appear as compact progress indicators instead of repeated chat messages, keeping the conversation readable during long repairs. ([#6746](https://github.com/Skyvern-AI/skyvern/pull/6746)) +- **Collapsible Sidebar with Avatar** — The left sidebar now features an edge toggle, a closed-state user avatar, and a smooth open/close animation for a more polished navigation experience. ([#6754](https://github.com/Skyvern-AI/skyvern/pull/6754)) +- **Structured Output Verified Before Completion** — Runs that produce structured data now verify the output satisfies the configured schema before marking the run as successfully complete. ([#6751](https://github.com/Skyvern-AI/skyvern/pull/6751)) +- **MCP Workflow Overlap Settings Preserved** — Overlap settings configured via MCP are now retained correctly when the workflow is subsequently edited, preventing unintentional resets. ([#6748](https://github.com/Skyvern-AI/skyvern/pull/6748)) +- **Browser Profile List Sorted Newest First** — The browser profile picker now orders profiles with the most recently created at the top, making recent profiles easier to find and select. ([#6739](https://github.com/Skyvern-AI/skyvern/pull/6739)) +- **Remote Browser Sessions Capped at Four Hours** — Remote browser sessions now have a maximum duration of four hours, preventing orphaned sessions from consuming resources indefinitely. +- **Debug Sessions Stay Open After Runs** — Browser sessions launched from the workflow editor in debug mode now remain alive after a run completes, so you can inspect the final page state without the session closing immediately. ([#6832](https://github.com/Skyvern-AI/skyvern/pull/6832)) +- **GPT-5 Mini via OpenRouter** — GPT-5 Mini is now available as a selectable model via OpenRouter. ([#6816](https://github.com/Skyvern-AI/skyvern/pull/6816)) +- **Extraction and AI Steps in Code-Block View** — Extraction and AI steps are now surfaced in the code-block run view, filling a gap in execution visibility. ([#6823](https://github.com/Skyvern-AI/skyvern/pull/6823)) +- **Color Palette for Grouped Workflow Tags** — Tags that belong to a group are now displayed with a curated color palette, making it easier to distinguish tag groups at a glance. ([#6801](https://github.com/Skyvern-AI/skyvern/pull/6801)) +- **Improved Authenticator 2FA Credential Setup** — The credential setup flow for authenticator-based 2FA has been improved with clearer guidance and a smoother configuration experience. ([#6783](https://github.com/Skyvern-AI/skyvern/pull/6783)) +- **Copilot Data-Write Blocks Default to Safe Failure Mode** — New Copilot-generated data-write blocks now default to `continue_on_failure=false`, preventing downstream blocks from running on bad data. ([#6779](https://github.com/Skyvern-AI/skyvern/pull/6779)) +- **End-State Screenshots for All Open Tabs** — Task runs now capture end-state screenshots for every open tab at completion, giving a full snapshot of the browser state. ([#6777](https://github.com/Skyvern-AI/skyvern/pull/6777)) +- **Partial Deliverable on Budget Exhaustion** — Tasks that hit their credit budget now return the best-effort deliverable collected so far instead of failing without output. ([#6778](https://github.com/Skyvern-AI/skyvern/pull/6778)) +- **Planner Supports EXTRACT_INFORMATION and GOTO_URL** — The task planner can now emit EXTRACT_INFORMATION and GOTO_URL actions, expanding the set of strategies available during automated planning. ([#6800](https://github.com/Skyvern-AI/skyvern/pull/6800)) +- **Copilot Completion Recognition Improvements** — The Copilot completion recognizer is now more authoritative and evidence-backed, with stable criteria counts and more reliable recognition across runs. ([#6789](https://github.com/Skyvern-AI/skyvern/pull/6789)) +- **Copilot Code Repair Loop Reliability** — The Copilot code authoring repair loop is now more reliable, reducing cases where stuck repairs would stall progress. ([#6792](https://github.com/Skyvern-AI/skyvern/pull/6792)) +- **Workflow Studio Browser and Panel Improvements** — The Workflow Studio editor now supports take-control and viewport centering for the live browser, with alignment and toolbar polish for the run/editor panels. ([#6810](https://github.com/Skyvern-AI/skyvern/pull/6810)) +- **Analytics Tag Filters Accept Flexible Input Shapes** — Analytics tag filters now accept labels, groups, or `group:label` pairs in all three supported filter shapes. ([#6802](https://github.com/Skyvern-AI/skyvern/pull/6802)) +- **Run ID Search Fallback on Runs List** — The runs list now falls back to searching by run ID when no parameter-value match is found, making it easier to locate specific runs. ([#6788](https://github.com/Skyvern-AI/skyvern/pull/6788)) + +## Bug fixes + +- Fixed login credentials not being reattached after editing a workflow via MCP, which could cause credential-gated runs to fail. ([#6761](https://github.com/Skyvern-AI/skyvern/pull/6761)) +- Fixed conditional routing logic not being preserved when loading a workflow stored in the v1 format. ([#6760](https://github.com/Skyvern-AI/skyvern/pull/6760)) +- Fixed the active run context being lost when switching between organizations, requiring a page refresh to restore the correct run view. ([#6753](https://github.com/Skyvern-AI/skyvern/pull/6753)) +- Fixed browser connection settings defined in a TaskV2 block not being forwarded to child task runs. ([#6750](https://github.com/Skyvern-AI/skyvern/pull/6750)) +- Fixed telephone number inputs dropping digits when bare NANP-format phone numbers were typed. ([#6826](https://github.com/Skyvern-AI/skyvern/pull/6826)) +- Fixed the workflow builder freezing due to an infinite FlowRenderer dimension/layout feedback loop. ([#6824](https://github.com/Skyvern-AI/skyvern/pull/6824)) +- Fixed the code editor crashing with a stack overflow when opened on large or deeply-nested JSON. ([#6817](https://github.com/Skyvern-AI/skyvern/pull/6817)) +- Fixed the app failing to load after a deploy due to stale webpack chunks; the app now auto-recovers via a cache-busting reload. ([#6818](https://github.com/Skyvern-AI/skyvern/pull/6818)) +- Fixed runs returning an incorrect terminal outcome when the extraction schema had been modified after the initial recording. ([#6815](https://github.com/Skyvern-AI/skyvern/pull/6815)) +- Fixed MCP workflow edits dropping non-credential parameter-to-block links. ([#6813](https://github.com/Skyvern-AI/skyvern/pull/6813)) +- Fixed address bar paste not working in the VNC streaming view. ([#6812](https://github.com/Skyvern-AI/skyvern/pull/6812)) +- Fixed newly added workflow blocks not receiving focus in the editor. ([#6809](https://github.com/Skyvern-AI/skyvern/pull/6809)) +- Fixed Copilot code-block file downloads being written to the wrong directory. ([#6797](https://github.com/Skyvern-AI/skyvern/pull/6797)) +- Fixed parameter-value search not appearing in the v2 runs list. ([#6796](https://github.com/Skyvern-AI/skyvern/pull/6796)) +- Fixed extra actions firing after selecting a combobox option during text entry. ([#6787](https://github.com/Skyvern-AI/skyvern/pull/6787)) +- Fixed shadow DOM text nodes being missed during element text extraction. ([#6791](https://github.com/Skyvern-AI/skyvern/pull/6791)) +- Fixed persistent browser sessions incorrectly reporting a cached session as live when it had become stale. + + + + + + +## New features + +- **OTP Fill at Runtime in Code-Block Sandbox** — Code blocks can now fill OTP fields using your stored TOTP credentials at runtime, enabling fully automated two-factor authentication flows without manual input. ([#6571](https://github.com/Skyvern-AI/skyvern/pull/6571)) +- **Uncapped Workflow Runtime** — Workflows can now be configured to run without a maximum time limit, enabling long-running automations that previously timed out. ([#6567](https://github.com/Skyvern-AI/skyvern/pull/6567)) +- **Code-First Code Block Editor** — Code blocks now feature a dedicated code editor with syntax highlighting, Jinja parameter hints, and a run timeline showing each action taken, giving developers full visibility and control over code-driven automations. ([#6604](https://github.com/Skyvern-AI/skyvern/pull/6604)) +- **Period-Aware Analytics Drill-Down** — The agent analytics drill-down panel now respects the selected time period (7/30/90/365 days or custom range) and shows per-agent average duration, credits per run, and period-over-period trends. +- **Agent Directory Tree** — The agents and folders list has been redesigned as a navigable directory tree, making it easier to browse and organize agents across nested folder hierarchies. ([#6631](https://github.com/Skyvern-AI/skyvern/pull/6631)) +- **GCP Self-Host: Storage and Vault** — Self-hosted deployments on Google Cloud Platform now support GCS artifact storage and GCP Secret Manager vault, enabling full GCP-native deployments. ([#6632](https://github.com/Skyvern-AI/skyvern/pull/6632)) +- **1Password Credential Picker** — Credential inputs now include an item picker for 1Password vaults, letting you browse and select vault items directly during credential setup. ([#6651](https://github.com/Skyvern-AI/skyvern/pull/6651)) +- **Typed Extraction Schema for Copilot Code Blocks** — Copilot-generated code blocks now support a typed data extraction schema (matching `data_schema` parity), enabling structured data output from code-block automations. ([#6674](https://github.com/Skyvern-AI/skyvern/pull/6674)) +- **SDK-Backed Remote Browser Provider** — Agents can now connect to external browser providers via SDK, enabling tighter integration with third-party remote browser services. ([#6687](https://github.com/Skyvern-AI/skyvern/pull/6687)) +- **Bitwarden Credential Integration** — Saved credentials can now be sourced directly from Bitwarden vaults, bringing Bitwarden to full UI and functional parity with other supported credential providers. ([#6729](https://github.com/Skyvern-AI/skyvern/pull/6729)) + +## Improvements + +- **Smarter Loop Planning for Repeated Work** — The task planner now prefers loop-based tasks when it detects repeated same-shaped operations, improving throughput on multi-item automation runs. ([#6575](https://github.com/Skyvern-AI/skyvern/pull/6575)) +- **Agent Auto-Acts on Stalled Pages** — When the agent re-evaluates a page that hasn't changed, it now applies steering heuristics and acts immediately instead of stalling. ([#6577](https://github.com/Skyvern-AI/skyvern/pull/6577)) +- **Final Answer Surfaced Directly** — The task agent now returns the final deliverable directly instead of spinning through redundant synthesis steps, reducing latency on completed tasks. ([#6574](https://github.com/Skyvern-AI/skyvern/pull/6574)) +- **Copilot Stops Stuck Repair Loops** — The Workflow Copilot now detects when consecutive repairs make no verified progress, halts after three attempts, and presents an honest escalation with the draft preserved for review. ([#6608](https://github.com/Skyvern-AI/skyvern/pull/6608)) +- **Voice Input Microphone Permission** — The browser microphone permission prompt now appears correctly when using voice dictation in the Prompt Box and Workflow Copilot. ([#6601](https://github.com/Skyvern-AI/skyvern/pull/6601)) +- **`page.evaluate` Recorded in Code Block Timeline** — JavaScript `page.evaluate(...)` calls made inside code blocks are now recorded as actions in the run timeline, filling a gap in execution visibility. ([#6600](https://github.com/Skyvern-AI/skyvern/pull/6600)) +- **Active Run Shown on Browser Session Page** — Browser session detail pages now display which workflow run currently occupies the session, making it easier to understand session usage. ([#6691](https://github.com/Skyvern-AI/skyvern/pull/6691)) +- **Distinct Visual for Terminate Actions** — Terminate actions now appear with a distinct amber triangle icon in the run timeline, making it easy to spot where a run was stopped. ([#6693](https://github.com/Skyvern-AI/skyvern/pull/6693)) +- **PDF Field Mapping by Positional Labels** — PDF form filling now uses positional field labels to improve accuracy when mapping data to form fields with ambiguous names. ([#6663](https://github.com/Skyvern-AI/skyvern/pull/6663)) +- **MiMo-V2.5 Model Support via OpenRouter** — Added Xiaomi MiMo-V2.5 as a selectable LLM via OpenRouter for agent runs. ([#6636](https://github.com/Skyvern-AI/skyvern/pull/6636)) +- **Settings "API Keys" Renamed to "General"** — The "API Keys" section in Settings has been renamed to "General" to better reflect its full scope of configuration options. ([#6642](https://github.com/Skyvern-AI/skyvern/pull/6642)) +- **Run URLs Surfaced in MCP and CLI** — The agent app URL and session recording URLs are now included in run responses returned via MCP and the CLI, making it easier to link directly to a live run. ([#6719](https://github.com/Skyvern-AI/skyvern/pull/6719)) +- **Email OTP Credentials Supported in Copilot** — The Copilot can now use stored email OTP credentials when generating and validating code-block automations that require email-based authentication. ([#6716](https://github.com/Skyvern-AI/skyvern/pull/6716)) +- **Auto-Fill Default Google Account for Sheets Blocks** — Google Sheets blocks now automatically select the default linked Google account, reducing manual setup when configuring Sheets integrations. ([#6715](https://github.com/Skyvern-AI/skyvern/pull/6715)) +- **Copilot Output Accurately Reflects Verified Results** — The Copilot no longer under-reports successfully verified output, ensuring the run summary accurately reflects what was accomplished. ([#6710](https://github.com/Skyvern-AI/skyvern/pull/6710)) +- **Runs Halt Reliably on Terminal Blockers** — When the agent encounters strong evidence of an unresolvable blocker (such as a persistent challenge page), it now stops immediately rather than continuing to attempt further actions. ([#6714](https://github.com/Skyvern-AI/skyvern/pull/6714)) +- **Workflow Save Performance Improved** — Cache invalidation on workflow save is now batched, eliminating a per-block N+1 query pattern that slowed saves for large workflows. ([#6718](https://github.com/Skyvern-AI/skyvern/pull/6718)) + +## Bug fixes + +- Fixed the Run Inputs panel collapsing unexpectedly when filtering Run History. ([#6573](https://github.com/Skyvern-AI/skyvern/pull/6573)) +- Fixed the agent incorrectly retrying click fallbacks after an action dispatch timed out, causing unintended interactions. ([#6569](https://github.com/Skyvern-AI/skyvern/pull/6569)) +- Fixed session recordings and page interpretation not resuming correctly after navigation, causing missed recording segments. ([#6568](https://github.com/Skyvern-AI/skyvern/pull/6568)) +- Fixed code block runs not generating a recording entry, causing the Recording tab to appear empty. ([#6607](https://github.com/Skyvern-AI/skyvern/pull/6607)) +- Fixed code block action screenshots not appearing on the run Overview page. ([#6605](https://github.com/Skyvern-AI/skyvern/pull/6605)) +- Fixed long usernames or email addresses hiding the sidebar collapse button on desktop. +- Fixed the FileParserBlock crashing instead of surfacing a clear error when it encountered an unparseable file. ([#6657](https://github.com/Skyvern-AI/skyvern/pull/6657)) +- Fixed the Inputs field missing from the Code view of code-first code blocks. ([#6633](https://github.com/Skyvern-AI/skyvern/pull/6633)) +- Fixed Copilot-synthesized download blocks not downloading the file during code replay. ([#6637](https://github.com/Skyvern-AI/skyvern/pull/6637), [#6638](https://github.com/Skyvern-AI/skyvern/pull/6638)) +- Fixed multi-step login flows not advancing when credentials were filled into JavaScript-gated fields. ([#6650](https://github.com/Skyvern-AI/skyvern/pull/6650)) +- Fixed an infinite update loop on the workflow build page when certain block configurations were active. ([#6660](https://github.com/Skyvern-AI/skyvern/pull/6660)) +- Fixed adopted-session file downloads not being available as run artifacts after the session ended. ([#6679](https://github.com/Skyvern-AI/skyvern/pull/6679)) +- Fixed the run timeline showing action and step rows for non-code blocks when they should only appear for code blocks. ([#6675](https://github.com/Skyvern-AI/skyvern/pull/6675)) +- Fixed workflow run actions not appearing in the timeline for all block types. +- Fixed workflow runs not stopping when a configured time limit was exceeded. ([#6677](https://github.com/Skyvern-AI/skyvern/pull/6677)) +- Fixed the analytics plan-tier badge showing the incorrect tier for some organizations. +- Fixed credits not being recorded when data was captured inside a Navigate block in task runs. ([#6698](https://github.com/Skyvern-AI/skyvern/pull/6698)) +- Fixed Copilot directory mode synthesizing incorrect code bindings. ([#6702](https://github.com/Skyvern-AI/skyvern/pull/6702)) +- Fixed FileUpload blocks silently skipping the upload when no file was provided, instead of recording a clean no-op and continuing. ([#6728](https://github.com/Skyvern-AI/skyvern/pull/6728)) +- Fixed the product tour not launching when triggered from the "Take a tour" menu option. ([#6727](https://github.com/Skyvern-AI/skyvern/pull/6727)) +- Fixed scanned PDF files being parsed as a single large page rather than processed page-by-page, which caused extraction to miss content on multi-page documents. ([#6722](https://github.com/Skyvern-AI/skyvern/pull/6722)) +- Fixed the block library drawer overflowing its container bounds in the workflow builder. ([#6721](https://github.com/Skyvern-AI/skyvern/pull/6721)) + + + - + ## New features @@ -52,6 +192,16 @@ keywords: - **GCS Storage Backend** — Added Google Cloud Storage as an artifact storage backend for self-hosted deployments. ([#6442](https://github.com/Skyvern-AI/skyvern/pull/6442)) - **Key-Optional Labels and Flexible Tag Filters** — Tags now support key-optional labels, and tag filter queries accept more flexible formats for richer agent organization. ([#6458](https://github.com/Skyvern-AI/skyvern/pull/6458)) - **Self-Serve Editor Onboarding** — New users in the workflow editor now see a get-started modal with intent routing and template cards, a 4-stop guided product tour, and smart empty states on the dashboard and runs pages to accelerate time-to-first-automation. ([#6475](https://github.com/Skyvern-AI/skyvern/pull/6475)) +- **Multi-Tab Browser Control** — The autonomous agent loop can now open and interact with multiple browser tabs simultaneously, enabling workflows that span across new tab links and pop-ups. ([#6507](https://github.com/Skyvern-AI/skyvern/pull/6507)) +- **Gemini 3.5 Flash Model Support** — Added Google Gemini 3.5 Flash as a selectable LLM for agent runs, providing a faster inference option. ([#6505](https://github.com/Skyvern-AI/skyvern/pull/6505)) +- **GCP Secret Manager Vault Adapter** — Added Google Cloud Secret Manager as a supported vault backend for storing and retrieving secrets in self-hosted deployments. ([#6488](https://github.com/Skyvern-AI/skyvern/pull/6488)) +- **Bulk Actions on Agents List** — You can now select multiple agents at once and apply bulk actions directly from the agents list page. ([#6485](https://github.com/Skyvern-AI/skyvern/pull/6485)) +- **Self-Service Credential Clears** — Saved credentials can now be individually cleared from the credentials settings page without deleting and recreating them. ([#6480](https://github.com/Skyvern-AI/skyvern/pull/6480)) +- **Per-Run Session Recordings as Artifacts** — Recordings are now clipped to each run's time window and stored as downloadable run-scoped artifacts. ([#6479](https://github.com/Skyvern-AI/skyvern/pull/6479)) +- **Credential-Aware Scouting in Code-Block Mode** — The Copilot now uses saved credentials while scouting pages for code-block generation and produces credential-typed code that fills login fields using your stored credentials. ([#6521](https://github.com/Skyvern-AI/skyvern/pull/6521)) +- **Claude Fable 5 Model Support** — Added Claude Fable 5 as a selectable model for agent runs. ([#6539](https://github.com/Skyvern-AI/skyvern/pull/6539)) +- **Live Browser Recording with Instant Placeholders** — Session recordings now commit navigation and wait blocks immediately as placeholders during live recording, with support for capture pause and draft interpretation. ([#6561](https://github.com/Skyvern-AI/skyvern/pull/6561)) +- **Act-and-Observe Copilot Scouting** — The Copilot's page scouting now uses act-and-observe interactions that return bounded page evidence alongside gathered actions, improving code-block generation accuracy. ([#6533](https://github.com/Skyvern-AI/skyvern/pull/6533)) ## Improvements @@ -64,6 +214,13 @@ keywords: - **Block Title Editable from Settings Sidebar** — Workflow block titles can now be edited from the right-hand settings sidebar, without needing to use the inline editor on the canvas. ([#6443](https://github.com/Skyvern-AI/skyvern/pull/6443)) - **API Reference Split into Agents and Runs Sections** — The public API reference is now organized into separate Agents and Runs sections for easier navigation. ([#6448](https://github.com/Skyvern-AI/skyvern/pull/6448)) - **Label-First Tag Editor and Flexible Tag Filter UI** — The tag editor now accepts standalone labels or `group:label` format in a single input, and the filter bar supports filtering by label, group, or group:label for richer agent organization. ([#6470](https://github.com/Skyvern-AI/skyvern/pull/6470)) +- **2FA Field Progressive Disclosure in Login Block** — Two-factor authentication fields in the login workflow block are now collapsed by default and expanded on demand, reducing visual noise for workflows that don't use 2FA. ([#6490](https://github.com/Skyvern-AI/skyvern/pull/6490)) +- **Persistent Browser Session Profile Generation Now Opt-In** — Automatic browser profile generation during persistent sessions is now opt-in, giving you explicit control over when profiles are captured. ([#6489](https://github.com/Skyvern-AI/skyvern/pull/6489)) +- **Icon-Only Status Pills on Mobile Viewports** — Status badges now render in a compact icon-only format on small screen widths, preventing layout overflow on mobile devices. ([#6481](https://github.com/Skyvern-AI/skyvern/pull/6481)) +- **Hardened Webhook Delivery** — Workflow webhook delivery is now more reliable, with improved handling of transient failures and prioritized delivery ordering. ([#6478](https://github.com/Skyvern-AI/skyvern/pull/6478)) +- **Reduced Live Recording Draft Latency** — Live session recordings now appear almost instantly with placeholder entries, with final blocks committed after a short debounce window. ([#6553](https://github.com/Skyvern-AI/skyvern/pull/6553)) +- **Copilot Stops on Terminal Blockers** — The Copilot now correctly halts when it reaches a condition that blocks further progress, preventing runaway sessions. ([#6535](https://github.com/Skyvern-AI/skyvern/pull/6535)) +- **Copilot Chat Shows Run Outcome Verdict** — The Copilot chat timeline now reflects the actual run outcome verdict, giving a more accurate view of what the agent accomplished. ([#6532](https://github.com/Skyvern-AI/skyvern/pull/6532)) ## Bug fixes @@ -72,6 +229,23 @@ keywords: - Fixed persistent-session screen recordings not appearing in the run view when the recording timestamp fell outside the run's time window. ([#6454](https://github.com/Skyvern-AI/skyvern/pull/6454)) - Fixed Copilot inconsistently asking for a URL before running discovery when the user had only named a site without providing one. ([#6473](https://github.com/Skyvern-AI/skyvern/pull/6473)) - Fixed Copilot asking for a URL after already successfully navigating to the requested site during discovery. ([#6472](https://github.com/Skyvern-AI/skyvern/pull/6472)) +- Fixed self-hosted deployments rejecting requests with "Invalid credentials" (HTTP 403) when the Docker UI injected an API key that the container's server incorrectly filtered out. +- Fixed persistent session recordings not being finalized when the video encoding process outlasted the session's cleanup window. ([#6519](https://github.com/Skyvern-AI/skyvern/pull/6519)) +- Fixed the saved credentials status indicator not updating until the page was manually refreshed. ([#6517](https://github.com/Skyvern-AI/skyvern/pull/6517)) +- Fixed the workflow run timeline rendering blocks in branch-tree order instead of execution order, producing a confusing step sequence. ([#6515](https://github.com/Skyvern-AI/skyvern/pull/6515)) +- Fixed downloaded files inside loop blocks being shared across all iterations instead of scoped to the block that downloaded them. ([#6487](https://github.com/Skyvern-AI/skyvern/pull/6487)) +- Fixed session recordings defaulting to an incorrect resolution; recordings now use Playwright's default viewport size. ([#6524](https://github.com/Skyvern-AI/skyvern/pull/6524)) +- Fixed Copilot failing to recognize and use a saved credential when the credential ID was typed with a space or hyphen separator instead of an underscore; malformed IDs are now normalized and resolved correctly. ([#6529](https://github.com/Skyvern-AI/skyvern/pull/6529)) +- Fixed Copilot chat showing workflow blocks as completed even when the overall run goal was not demonstrated — the recorded run outcome verdict is now surfaced in the chat narrative. ([#6528](https://github.com/Skyvern-AI/skyvern/pull/6528)) +- Fixed Copilot browser sessions incorrectly blocking localhost navigation during page scouting, which could prevent evidence from being gathered from locally-served pages. ([#6527](https://github.com/Skyvern-AI/skyvern/pull/6527)) +- Fixed single-element JSON arrays being unwrapped to a scalar when passed as workflow run inputs. ([#6560](https://github.com/Skyvern-AI/skyvern/pull/6560)) +- Fixed code blocks silently dropping non-dict return values instead of passing them through to downstream blocks. ([#6556](https://github.com/Skyvern-AI/skyvern/pull/6556)) +- Fixed the task agent ignoring the `max_iterations` override, causing runs to exceed their configured step limit. ([#6544](https://github.com/Skyvern-AI/skyvern/pull/6544)) +- Fixed generated workflow parameter keys being duplicated, which caused a hard failure when the workflow was run. ([#6543](https://github.com/Skyvern-AI/skyvern/pull/6543)) +- Fixed task runs completing successfully even when a required sub-goal had not been satisfied. ([#6546](https://github.com/Skyvern-AI/skyvern/pull/6546)) +- Fixed the credential-clear confirmation dialog being hidden behind other overlays. ([#6542](https://github.com/Skyvern-AI/skyvern/pull/6542)) +- Fixed click actions on elements wrapped in disabled containers not being retargeted to the clickable child element. ([#6540](https://github.com/Skyvern-AI/skyvern/pull/6540)) +- Fixed Copilot loop detector incorrectly triggering at block dispatch boundaries. ([#6550](https://github.com/Skyvern-AI/skyvern/pull/6550)) @@ -261,8 +435,8 @@ keywords: - - + + ## Improvements @@ -279,8 +453,8 @@ keywords: - - + + ## New features @@ -324,38 +498,35 @@ keywords: - - + + ## New features - **Adaptive Caching (Script Generation)** — Skyvern can now learn from repeated workflow runs and generate cached scripts that speed up future executions. When a block runs successfully, Skyvern records a reusable script and replays it on subsequent runs, falling back to the AI agent only if the script fails. ([#4908](https://github.com/Skyvern-AI/skyvern/pull/4908), [#4916](https://github.com/Skyvern-AI/skyvern/pull/4916), [#4917](https://github.com/Skyvern-AI/skyvern/pull/4917), [#4920](https://github.com/Skyvern-AI/skyvern/pull/4920), [#4922](https://github.com/Skyvern-AI/skyvern/pull/4922), [#4931](https://github.com/Skyvern-AI/skyvern/pull/4931)) - - **Workflow Trigger Block** — A new block type that lets one workflow trigger another from within the workflow editor. Chain workflows together as building blocks for complex automations. ([#4885](https://github.com/Skyvern-AI/skyvern/pull/4885)) - - **Browser-based CLI Signup** — New users can sign up for Skyvern Cloud directly from the CLI. Running `skyvern signup` opens a browser flow that creates your account and stores your API key locally. ([#4925](https://github.com/Skyvern-AI/skyvern/pull/4925)) - - **Interactive ngrok Tunnel** — `skyvern browser serve` can now create an ngrok tunnel automatically, making it easy to expose your local browser to Skyvern Cloud without manual network configuration. ([#4924](https://github.com/Skyvern-AI/skyvern/pull/4924)) - - **South Korea Proxy Location** — Added `RESIDENTIAL_KR` as a proxy geolocation option for automations targeting South Korean websites. ([#4918](https://github.com/Skyvern-AI/skyvern/pull/4918)) - - **Downloaded Files Tab** — Browser session detail pages now include a Downloaded Files tab for viewing and accessing files captured during a session. ([#4911](https://github.com/Skyvern-AI/skyvern/pull/4911)) - - **CDP Proxy Authentication** — Remote browser connections now support authenticated proxies via the CDP `Fetch.authRequired` protocol. ([#4936](https://github.com/Skyvern-AI/skyvern/pull/4936)) - - **Remote Browser Download Interception** — File downloads triggered by XHR requests and other non-navigation events are now captured when using remote CDP browser connections. ([#4906](https://github.com/Skyvern-AI/skyvern/pull/4906), [#4921](https://github.com/Skyvern-AI/skyvern/pull/4921), [#4934](https://github.com/Skyvern-AI/skyvern/pull/4934)) +- **`current_date` Reserved Parameter** — Workflows now have a built-in `current_date` parameter that resolves to today's date automatically. ([#4854](https://github.com/Skyvern-AI/skyvern/pull/4854)) +- **Reserved Parameters in Block Editor** — The workflow block parameter picker now shows reserved system parameters, making them discoverable without memorizing names. ([#4857](https://github.com/Skyvern-AI/skyvern/pull/4857)) +- **Natural Language Loop `data_schema`** — Loop blocks driven by natural language extraction now support a `data_schema` field for consistent output structure. ([#4851](https://github.com/Skyvern-AI/skyvern/pull/4851)) +- **Gemini 3.1 Pro and Inception Mercury-2** — Two new LLM engine options for your automations. ([#4847](https://github.com/Skyvern-AI/skyvern/pull/4847)) +- **MCP Server on API Service** — The MCP remote server is now mounted at `/mcp` on the existing API, eliminating the need for a separate process. ([#4843](https://github.com/Skyvern-AI/skyvern/pull/4843)) ## Improvements - **Renamed "Login-Free" to "Saved Profile"** — Clearer terminology throughout the UI. Browser session checkbox wording has also been improved. ([#4914](https://github.com/Skyvern-AI/skyvern/pull/4914)) - - **TOTP Verification Improvements** — Better timer handling, expired code retry, 2FA banner suppression, and more reliable goal-text extraction during TOTP flows. ([#4860](https://github.com/Skyvern-AI/skyvern/pull/4860)) - - **TOTP Webhook includes `workflow_permanent_id`** — Easier to identify which workflow is requesting a 2FA code. ([#4871](https://github.com/Skyvern-AI/skyvern/pull/4871)) - - **Consolidated Gemini 3 Pro Model Key** — `VERTEX_GEMINI_3_PRO` now auto-resolves to the latest version, so you no longer need to update config when Google releases point versions. ([#4926](https://github.com/Skyvern-AI/skyvern/pull/4926)) - - **Browser Rotation for Remote Connections** — Remote browser environments now support context rotation, improving reliability for long-running automations. ([#4929](https://github.com/Skyvern-AI/skyvern/pull/4929)) +- Workflow save validation (HTTP 422) now returns clear, field-level error messages. ([#4852](https://github.com/Skyvern-AI/skyvern/pull/4852)) +- Browser launch errors now show actionable messages instead of raw exceptions. ([#4844](https://github.com/Skyvern-AI/skyvern/pull/4844)) +- Cloud LLM fallback overrides are now properly applied when selecting models. ([#4839](https://github.com/Skyvern-AI/skyvern/pull/4839)) ## Bug fixes @@ -367,151 +538,53 @@ keywords: - MCP remote auth token comparison no longer fails on encrypted tokens. ([#4863](https://github.com/Skyvern-AI/skyvern/pull/4863)) - `skyvern quickstart` now handles local PostgreSQL without a pre-existing `skyvern` role. ([#4878](https://github.com/Skyvern-AI/skyvern/pull/4878)) - WebSocket URLs through ngrok tunnels now rewrite correctly for live browser streaming. ([#4927](https://github.com/Skyvern-AI/skyvern/pull/4927)) - - - - - - -## New features - -- **`current_date` Reserved Parameter** — Workflows now have a built-in `current_date` parameter that resolves to today's date automatically. ([#4854](https://github.com/Skyvern-AI/skyvern/pull/4854)) - -- **Reserved Parameters in Block Editor** — The workflow block parameter picker now shows reserved system parameters, making them discoverable without memorizing names. ([#4857](https://github.com/Skyvern-AI/skyvern/pull/4857)) - -- **Natural Language Loop `data_schema`** — Loop blocks driven by natural language extraction now support a `data_schema` field for consistent output structure. ([#4851](https://github.com/Skyvern-AI/skyvern/pull/4851)) - -- **Gemini 3.1 Pro and Inception Mercury-2** — Two new LLM engine options for your automations. ([#4847](https://github.com/Skyvern-AI/skyvern/pull/4847)) - -- **MCP Server on API Service** — The MCP remote server is now mounted at `/mcp` on the existing API, eliminating the need for a separate process. ([#4843](https://github.com/Skyvern-AI/skyvern/pull/4843)) - -## Improvements - -- Workflow save validation (HTTP 422) now returns clear, field-level error messages. ([#4852](https://github.com/Skyvern-AI/skyvern/pull/4852)) -- Browser launch errors now show actionable messages instead of raw exceptions. ([#4844](https://github.com/Skyvern-AI/skyvern/pull/4844)) -- Cloud LLM fallback overrides are now properly applied when selecting models. ([#4839](https://github.com/Skyvern-AI/skyvern/pull/4839)) - -## Bug fixes - - Conditional blocks no longer crash when all branches use Jinja templates. ([#4849](https://github.com/Skyvern-AI/skyvern/pull/4849)) - Conditional blocks no longer misinterpret resolved Jinja template variables. ([#4841](https://github.com/Skyvern-AI/skyvern/pull/4841)) - - + + ## New features - **Browser Profile Testing UI (Frontend)** — The Browsers page now lets you launch a test run against a saved browser profile to confirm its login state is still valid before scheduling production workflows. ([#4833](https://github.com/Skyvern-AI/skyvern/pull/4833)) - - **2FA Verification Code UI and Notifications** — When a workflow pauses for a 2FA code, the UI now shows a clear prompt and pushes a notification so you don't miss it. ([#4787](https://github.com/Skyvern-AI/skyvern/pull/4787)) - -## Improvements - -- **2FA Backend Cleanup** — Refactored the 2FA pipeline to make code delivery more reliable across both the UI form and the API endpoint. ([#4826](https://github.com/Skyvern-AI/skyvern/pull/4826)) - - - - - - -## New features - - **Gated Admin Impersonation for MCP API Keys** — Adds gated admin controls so support engineers can impersonate an organization scope when troubleshooting MCP-based automations, with audit-friendly access controls. ([#4822](https://github.com/Skyvern-AI/skyvern/pull/4822)) - -## Improvements - -- **Environment Variables Override Mounted API Keys** — In Skyvern's Streamlit-based deployment, env vars now take precedence over keys baked into the image, making local overrides simpler. ([#4824](https://github.com/Skyvern-AI/skyvern/pull/4824)) - - - - - - -## New features - - **Browser Profile Testing** — Test browser profiles directly from the UI to verify that saved login sessions are still valid. Workflows can also run with a saved browser profile, enabling login-free automations that reuse authenticated sessions. ([#4818](https://github.com/Skyvern-AI/skyvern/pull/4818)) - -## Bug fixes - -- When both a TOTP credential and a webhook are configured for 2FA, the credential is now correctly prioritized. ([#4811](https://github.com/Skyvern-AI/skyvern/pull/4811)) - - - - - - -## New features - - **Skyvern Skills Package** — The new `skyvern skill` CLI command provides pre-built workflow templates and reference documentation for common automation patterns. ([#4817](https://github.com/Skyvern-AI/skyvern/pull/4817)) - - **Clear Cached Scripts API** — A new endpoint lets you clear cached automation scripts for a workflow when a target website changes and you want to force Skyvern to re-learn. ([#4809](https://github.com/Skyvern-AI/skyvern/pull/4809)) - - - - - - -## New features - - **Automatic 2FA Detection Without TOTP Credentials** — Skyvern now detects when a website asks for a 2FA code even without pre-configured TOTP credentials. The system pauses and waits for you to provide the code via the UI or webhook. ([#4786](https://github.com/Skyvern-AI/skyvern/pull/4786)) - - **Full CLI Parity with MCP** — The CLI now supports browser session management, credential management, block operations, and enhanced workflow commands. Everything available through MCP is now accessible from the command line. ([#4789](https://github.com/Skyvern-AI/skyvern/pull/4789), [#4792](https://github.com/Skyvern-AI/skyvern/pull/4792), [#4793](https://github.com/Skyvern-AI/skyvern/pull/4793)) - - **Claude Opus 4.6 CUA Support** — Anthropic's Claude Opus 4.6 is now available as a Computer Use Agent model for driving browser interactions. ([#4780](https://github.com/Skyvern-AI/skyvern/pull/4780)) - -## Improvements - -- The workflow run timeline now properly displays loop iterations and conditional branches, making complex runs easier to debug. ([#4782](https://github.com/Skyvern-AI/skyvern/pull/4782)) - -## Bug fixes - -- Fixed conditional blocks evaluating the wrong value after Jinja template rendering. ([#4801](https://github.com/Skyvern-AI/skyvern/pull/4801)) -- Fixed MFA resolution priority when both TOTP credentials and webhooks are configured. ([#4800](https://github.com/Skyvern-AI/skyvern/pull/4800)) - - - - - - -## New features - - **Anthropic Claude Opus 4.6** — Added Claude Opus 4.6 as an available LLM engine for web automation tasks. ([#4777](https://github.com/Skyvern-AI/skyvern/pull/4777), [#4778](https://github.com/Skyvern-AI/skyvern/pull/4778)) - - - - - - -## New features - - **Unified Browser Task Block** — The Navigation V1 and Task V2 blocks have been merged into a single **Browser Task** block. The block now exposes both engines as a dropdown, simplifying the workflow editor and reducing block-type confusion. ([#4695](https://github.com/Skyvern-AI/skyvern/pull/4695)) - - **Block Discovery MCP Tools** — Added MCP tools that let AI assistants enumerate available block types and their schemas, plus richer instructions for building workflows through MCP. ([#4683](https://github.com/Skyvern-AI/skyvern/pull/4683)) - - **Edit Existing Credentials API** — A new endpoint lets you update credential values without deleting and recreating them, preserving credential IDs across changes. ([#4693](https://github.com/Skyvern-AI/skyvern/pull/4693)) - - **Filter Workflow Runs by Error Code and Search Key** — The workflow runs list endpoint now supports `error_code` and `search_key` query parameters for narrowing down failed runs. ([#4694](https://github.com/Skyvern-AI/skyvern/pull/4694)) - - **GPT-5 Mini in Model Selector** — GPT-5 Mini is now selectable as an LLM engine. GPT-5 has been hidden in favor of more reliable variants. ([#4686](https://github.com/Skyvern-AI/skyvern/pull/4686)) ## Improvements +- **2FA Backend Cleanup** — Refactored the 2FA pipeline to make code delivery more reliable across both the UI form and the API endpoint. ([#4826](https://github.com/Skyvern-AI/skyvern/pull/4826)) +- **Environment Variables Override Mounted API Keys** — In Skyvern's Streamlit-based deployment, env vars now take precedence over keys baked into the image, making local overrides simpler. ([#4824](https://github.com/Skyvern-AI/skyvern/pull/4824)) +- The workflow run timeline now properly displays loop iterations and conditional branches, making complex runs easier to debug. ([#4782](https://github.com/Skyvern-AI/skyvern/pull/4782)) - **Block Failure Reasons Surfaced in UI** — Every block type now shows its `failure_reason` in the run timeline, making debugging non-obvious failures faster. ([#4672](https://github.com/Skyvern-AI/skyvern/pull/4672)) - - **Webhook Payload Truncation in Logs** — Large webhook payloads are now truncated in log entries, keeping logs readable without losing context. ([#4701](https://github.com/Skyvern-AI/skyvern/pull/4701)) ## Bug fixes +- When both a TOTP credential and a webhook are configured for 2FA, the credential is now correctly prioritized. ([#4811](https://github.com/Skyvern-AI/skyvern/pull/4811)) +- Fixed conditional blocks evaluating the wrong value after Jinja template rendering. ([#4801](https://github.com/Skyvern-AI/skyvern/pull/4801)) +- Fixed MFA resolution priority when both TOTP credentials and webhooks are configured. ([#4800](https://github.com/Skyvern-AI/skyvern/pull/4800)) - Fixed the cursor jumping to the end while editing conditional expressions in the workflow editor. ([#4682](https://github.com/Skyvern-AI/skyvern/pull/4682)) - Fixed dotted Jinja variables (e.g. `{{ block_output.field }}`) rendering incorrectly in the expression preview. ([#4684](https://github.com/Skyvern-AI/skyvern/pull/4684)) - The API now returns 404 instead of 500 when a workflow is not found. ([#4699](https://github.com/Skyvern-AI/skyvern/pull/4699)) - - + + ## New features @@ -541,8 +614,8 @@ keywords: - - + + ## New features @@ -574,8 +647,8 @@ keywords: - - + + ## New features @@ -605,26 +678,32 @@ keywords: - - + + ## New features - **Browser Sessions v2 (Backend)** — A new persistent browser session architecture with cleaner lifecycle management, better resource reuse, and improved reliability for long-running automations. The frontend follows in v1.0.11. ([#4515](https://github.com/Skyvern-AI/skyvern/pull/4515)) - - **General Text CAPTCHA Solver** — Skyvern can now solve a wider range of text-based CAPTCHAs without site-specific configuration. ([#4517](https://github.com/Skyvern-AI/skyvern/pull/4517)) - - **TOTP Webhook Retries** — TOTP webhook delivery now retries automatically on transient failures, reducing the rate of "no code received" errors during 2FA flows. ([#4518](https://github.com/Skyvern-AI/skyvern/pull/4518)) - - **Block-Label Validation in `run_blocks`** — The `run_blocks` endpoint now validates block labels up front, returning a clear error instead of failing partway through execution. ([#4500](https://github.com/Skyvern-AI/skyvern/pull/4500)) +- **Print PDF Block** — A new workflow block that prints the current page to PDF and saves it to your run's downloaded files, useful for archiving receipts, invoices, and reports. ([#4452](https://github.com/Skyvern-AI/skyvern/pull/4452)) +- **HTTP File Downloads** — The HTTP Request block can now download files directly via HTTP (not just GET JSON), enabling workflows that pull artifacts from authenticated APIs. ([#4440](https://github.com/Skyvern-AI/skyvern/pull/4440)) +- **Workflow Copilot Streaming with Cancel** — The Workflow Copilot now streams its responses with a cancel button, so you can stop generation mid-stream if you want to refine your prompt. ([#4437](https://github.com/Skyvern-AI/skyvern/pull/4437), [#4456](https://github.com/Skyvern-AI/skyvern/pull/4456)) +- **"Execute on Any Outcome" (Finally) Block Option** — Blocks can now be configured to run regardless of whether prior blocks succeeded, enabling cleanup steps and "always send report" workflows. ([#4443](https://github.com/Skyvern-AI/skyvern/pull/4443)) +- **Persistent Browser Session Uptime Billing** — Billing v2 now meters persistent browser session uptime separately, with PostHog feature flags controlling rollout. ([#4444](https://github.com/Skyvern-AI/skyvern/pull/4444)) +- **GPT-5 Mini Flex Engine** — Added GPT-5 Mini Flex as an available LLM engine. ([#4432](https://github.com/Skyvern-AI/skyvern/pull/4432)) +- **Clipboard Copy over HTTP** — The browser now exposes clipboard copy functionality that works in HTTP-served browser sessions, not just HTTPS. ([#4446](https://github.com/Skyvern-AI/skyvern/pull/4446)) ## Improvements - **Confirmation Dialog Shows Affected Blocks** — Deleting a parameter or block now lists every block that references it, so you don't accidentally break downstream logic. ([#4519](https://github.com/Skyvern-AI/skyvern/pull/4519)) - - **Workflow Updates Return 409 on Race** — Concurrent workflow updates now return HTTP 409 instead of silently overwriting each other. ([#4510](https://github.com/Skyvern-AI/skyvern/pull/4510)) - - **Tiktoken Caching** — Tokenizer instances are now cached, cutting per-step overhead on token counting. ([#4521](https://github.com/Skyvern-AI/skyvern/pull/4521)) +- **HTTP Block Boolean and Integer Templates** — The HTTP Request block now correctly passes boolean and integer template values without coercing them to strings. ([#4435](https://github.com/Skyvern-AI/skyvern/pull/4435)) +- **Bitwarden and Azure Vault Tooltips** — Added inline help tooltips explaining how to set up Bitwarden and Azure Key Vault credential sources. ([#4447](https://github.com/Skyvern-AI/skyvern/pull/4447)) +- **Smaller S3 Objects Use STANDARD Tier** — Storage cost optimization: small artifacts now use STANDARD instead of GLACIER_IR. ([#4453](https://github.com/Skyvern-AI/skyvern/pull/4453)) +- **Reduced Max SVG Parsing** — Lowered the SVG parsing cap to prevent runaway memory usage on pathological pages. ([#4430](https://github.com/Skyvern-AI/skyvern/pull/4430)) ## Bug fixes @@ -634,39 +713,8 @@ keywords: - - - -## New features - -- **Print PDF Block** — A new workflow block that prints the current page to PDF and saves it to your run's downloaded files, useful for archiving receipts, invoices, and reports. ([#4452](https://github.com/Skyvern-AI/skyvern/pull/4452)) - -- **HTTP File Downloads** — The HTTP Request block can now download files directly via HTTP (not just GET JSON), enabling workflows that pull artifacts from authenticated APIs. ([#4440](https://github.com/Skyvern-AI/skyvern/pull/4440)) - -- **Workflow Copilot Streaming with Cancel** — The Workflow Copilot now streams its responses with a cancel button, so you can stop generation mid-stream if you want to refine your prompt. ([#4437](https://github.com/Skyvern-AI/skyvern/pull/4437), [#4456](https://github.com/Skyvern-AI/skyvern/pull/4456)) - -- **"Execute on Any Outcome" (Finally) Block Option** — Blocks can now be configured to run regardless of whether prior blocks succeeded, enabling cleanup steps and "always send report" workflows. ([#4443](https://github.com/Skyvern-AI/skyvern/pull/4443)) - -- **Persistent Browser Session Uptime Billing** — Billing v2 now meters persistent browser session uptime separately, with PostHog feature flags controlling rollout. ([#4444](https://github.com/Skyvern-AI/skyvern/pull/4444)) - -- **GPT-5 Mini Flex Engine** — Added GPT-5 Mini Flex as an available LLM engine. ([#4432](https://github.com/Skyvern-AI/skyvern/pull/4432)) - -- **Clipboard Copy over HTTP** — The browser now exposes clipboard copy functionality that works in HTTP-served browser sessions, not just HTTPS. ([#4446](https://github.com/Skyvern-AI/skyvern/pull/4446)) - -## Improvements - -- **HTTP Block Boolean and Integer Templates** — The HTTP Request block now correctly passes boolean and integer template values without coercing them to strings. ([#4435](https://github.com/Skyvern-AI/skyvern/pull/4435)) - -- **Bitwarden and Azure Vault Tooltips** — Added inline help tooltips explaining how to set up Bitwarden and Azure Key Vault credential sources. ([#4447](https://github.com/Skyvern-AI/skyvern/pull/4447)) - -- **Smaller S3 Objects Use STANDARD Tier** — Storage cost optimization: small artifacts now use STANDARD instead of GLACIER_IR. ([#4453](https://github.com/Skyvern-AI/skyvern/pull/4453)) - -- **Reduced Max SVG Parsing** — Lowered the SVG parsing cap to prevent runaway memory usage on pathological pages. ([#4430](https://github.com/Skyvern-AI/skyvern/pull/4430)) - - - - - + + ## New features diff --git a/docs/cloud/account-settings/billing-usage.mdx b/docs/cloud/account-settings/billing-usage.mdx index af2009e0b..e63cfd348 100644 --- a/docs/cloud/account-settings/billing-usage.mdx +++ b/docs/cloud/account-settings/billing-usage.mdx @@ -23,14 +23,14 @@ Open **Billing** in the sidebar to see available plans and your current balance. Billing page showing plan options and current balance -| Plan | Price | Credits/month | Estimated actions | Concurrent runs | -|------|-------|---------------|-------------------|-----------------| -| **Free** | $0 | 1,000 | ~170 | 1 | -| **Hobby** | $29/month | 30,000 | ~1,200 | 10 | -| **Pro** | $149/month | 150,000 | ~6,200 | 25 | +| Plan | Price | Credits | Estimated actions | Concurrent runs | +|------|-------|---------|-------------------|-----------------| +| **Free** | $0 | 5,000 one-time | ~200 | 1 | +| **Hobby** | $29/month | 30,000/month | ~1,200 | 10 | +| **Pro** | $149/month | 150,000/month | ~6,200 | 25 | | **Enterprise** | Custom | Unlimited | Unlimited | Unlimited | -**Free** is available to all new accounts with zero commitment. **Hobby** and **Pro** are monthly subscriptions. Click **Switch to Hobby** or **Switch to Pro** to change plans. Your remaining dollar balance is applied as a credit toward your first subscription payment. +**Free** is available to all new accounts with a one-time credit grant and zero commitment. **Hobby** and **Pro** are monthly subscriptions. Click **Switch to Hobby** or **Switch to Pro** to change plans. Your remaining dollar balance is applied as a credit toward your first subscription payment. Need HIPAA compliance, SOC-2 reports, custom code blocks, or dedicated support? Use the **Book a call** link at the bottom of the Billing page to discuss **Enterprise**, or visit [skyvern.com/pricing](https://skyvern.com/pricing) for a complete feature comparison. @@ -40,7 +40,7 @@ Need HIPAA compliance, SOC-2 reports, custom code blocks, or dedicated support? -**1,000 credits/month** (~170 actions) +**5,000 one-time credits** (~200 actions) Perfect for evaluating Skyvern and prototyping automations. @@ -117,11 +117,11 @@ The **Your Current Balance** section shows your remaining dollar balance. Credit ## FAQ -Each browser action the AI takes (clicking, typing, navigating, extracting) costs one credit. Credits are deducted in real time as runs execute. Your plan includes a monthly credit allowance that resets each billing cycle. Unused credits don't roll over. +Each browser action the AI takes (clicking, typing, navigating, extracting) costs one credit. Credits are deducted in real time as runs execute. Free accounts receive a one-time credit grant. Paid plans include a monthly credit allowance that resets each billing cycle. Unused included credits don't roll over. -Yes. If you exhaust your monthly allowance, you can purchase additional credits from the Billing page. You can also upgrade to a higher plan for a larger monthly allowance. +Yes. If you exhaust your included allowance, you can purchase additional credits from the Billing page. You can also upgrade to a higher plan for a larger monthly allowance. diff --git a/docs/cloud/browser-management/browser-sessions.mdx b/docs/cloud/browser-management/browser-sessions.mdx index 95bec6f99..5b72920e9 100644 --- a/docs/cloud/browser-management/browser-sessions.mdx +++ b/docs/cloud/browser-management/browser-sessions.mdx @@ -128,7 +128,7 @@ From the session detail page, click **Stop**. A confirmation dialog appears. The Sessions also close automatically when their timeout expires, **even if a task is still running**. Set timeouts with enough margin for your longest expected task. -Closed sessions cannot be reopened. If you need the same login state later, create a [browser profile](/cloud/browser-management/browser-profiles) before closing. +Closed sessions cannot be reopened, and a session only saves its browser state at close when profile generation is enabled for it. The **Save browser session** credential flow handles this automatically; for API-created sessions, see [Save a session's profile](/developers/optimization/browser-sessions#save-a-sessions-profile). If you need the same login state later, capture it as a [browser profile](/cloud/browser-management/browser-profiles) before closing. --- diff --git a/docs/cloud/building-agents/configure-blocks.mdx b/docs/cloud/building-agents/configure-blocks.mdx index 6e4ad8c5a..bda4fd9ca 100644 --- a/docs/cloud/building-agents/configure-blocks.mdx +++ b/docs/cloud/building-agents/configure-blocks.mdx @@ -58,9 +58,13 @@ Browser-based blocks (Browser Task, Browser Action, Extraction, Login, File Down | **Max Steps Override** | Cap the number of AI reasoning steps | | **Disable Cache** | Skip cached script execution | | **TOTP Identifier** | Link TOTP credentials for 2FA handling | -| **TOTP Verification URL** | Endpoint for fetching TOTP codes | +| **TOTP Verification URL** | Endpoint this block polls when it needs a 2FA code | | **Error Code Mapping** | Map error conditions to custom error codes (JSON) | + +Set **TOTP Verification URL** on the block that may encounter 2FA. Top-level workflow run `totp_url` values do not automatically populate empty block fields. + + --- ## Browser Automation diff --git a/docs/cloud/managing-credentials/external-providers.mdx b/docs/cloud/managing-credentials/external-providers.mdx index d568ef68f..c9b60dfd7 100644 --- a/docs/cloud/managing-credentials/external-providers.mdx +++ b/docs/cloud/managing-credentials/external-providers.mdx @@ -363,7 +363,7 @@ Restart Skyvern after setting these variables. | Status stays Inactive | Verify the API base URL is a valid URL and the API token is not empty. The configuration is validated on save but does not make a live request to your server. | | Credentials not created | Review your API logs for auth errors. Ensure the response includes an `id` field. Skyvern expects HTTP 200 for all operations. | | Credentials not retrieved | Ensure the GET response includes all required fields for the credential type (`username` and `password` for passwords, all card fields for credit cards, `secret_value` for secrets). | -| Env config not working | Restart Skyvern after setting variables. Verify `CREDENTIAL_VAULT_TYPE=custom` is set and both URL and token are provided. The default vault type is `bitwarden`, so this variable must be explicitly set. | +| Env config not working | Restart Skyvern after setting variables. Verify `CREDENTIAL_VAULT_TYPE=custom` is set and both URL and token are provided. Deployment defaults vary, so `custom` must be explicitly set for an external service. | --- diff --git a/docs/cloud/managing-credentials/totp-setup.mdx b/docs/cloud/managing-credentials/totp-setup.mdx index b57f691eb..c5954faae 100644 --- a/docs/cloud/managing-credentials/totp-setup.mdx +++ b/docs/cloud/managing-credentials/totp-setup.mdx @@ -39,8 +39,8 @@ The preferred method. Skyvern generates valid 6-digit codes on demand during log Choose **Authenticator App** from the three options. - - Enter the secret key into the **Authenticator Key** field and click **Save**. + + Enter the secret key into the **Authenticator Key** field, or click **Scan QR** to upload a QR code image from the site's 2FA setup screen. Then click **Save**. Add Credential dialog with Authenticator App selected and the Authenticator Key field @@ -53,7 +53,7 @@ The secret key is the base32-encoded string behind the QR code you'd normally sc - **LastPass**: Edit the login → Advanced Settings → copy the TOTP secret - **Site settings**: Many sites show a "Can't scan?" link during 2FA setup that reveals the text key -If you only have a QR code, decode it to extract the `secret=` parameter from the `otpauth://totp/...?secret=BASE32KEY` URI. +If you only have a QR code, use **Scan QR** in the credential form or paste the full `otpauth://totp/...?secret=BASE32KEY` URI. --- @@ -171,7 +171,13 @@ This turns email-based 2FA into something nearly as automated as an authenticato ## Server-fetched codes (totp_url) -Instead of pushing codes to Skyvern, you can have Skyvern pull them from your server. Pass `totp_url` when running a [task](/cloud/getting-started/run-from-code) or [agent](/cloud/building-agents/build-an-agent), and Skyvern will call your endpoint when it needs a verification code. +Instead of pushing codes to Skyvern, you can have Skyvern pull them from your server. Skyvern calls your endpoint only when the automation reaches a page that asks for a verification code. + + +For saved agents or workflows, configure the endpoint on the block that performs the login. Open the browser, action, file download, or login block and set **TOTP Verification URL**. Passing `totp_url` only in the top-level workflow run request does not automatically populate a block whose **TOTP Verification URL** is empty. + + +If the endpoint changes per run, create a workflow parameter such as `totp_url`, set the block's **TOTP Verification URL** to `{{ totp_url }}`, and pass the URL in `parameters` when starting the run. Your endpoint must accept a POST request with `task_id`, `workflow_run_id`, and `workflow_permanent_id` in the body, and return: @@ -246,7 +252,7 @@ curl -X GET "https://api.skyvern.com/v1/credentials/totp?totp_identifier=user@ex | `otp_type` | Filter by `totp` or `magic_link` | | `limit` | Number of records (default 50, max 200) | -Returns a list ordered newest first. Only codes created within the last 10 minutes (configurable via `TOTP_LIFESPAN_MINUTES`) are returned. +Returns historical records ordered newest first, capped by `limit`. Runtime polling still only uses unexpired codes within `TOTP_LIFESPAN_MINUTES`.
)} - {profile.name} +
+
+ + {profile.name} + + {profile.is_managed ? ( + + Auto-managed + + ) : null} +
+
+ + {profile.browser_profile_id} + + +
+
{profile.description ? ( diff --git a/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesList.tsx b/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesList.tsx index 392fb4089..c4a358a52 100644 --- a/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesList.tsx +++ b/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesList.tsx @@ -49,9 +49,10 @@ import { BrowserProfileItem } from "./BrowserProfileItem"; type Props = { searchKey?: string; + managed?: boolean; }; -function BrowserProfilesList({ searchKey }: Props = {}) { +function BrowserProfilesList({ searchKey, managed }: Props = {}) { const [searchParams, setSearchParams] = useSearchParams(); const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1; const itemsPerPage = searchParams.get("page_size") @@ -84,12 +85,14 @@ function BrowserProfilesList({ searchKey }: Props = {}) { page, page_size: itemsPerPage, searchKey, + managed, }); const { data: nextPageProfiles } = useBrowserProfilesQuery({ page: page + 1, page_size: itemsPerPage, searchKey, + managed, enabled: (profiles?.length ?? 0) === itemsPerPage, }); @@ -124,7 +127,7 @@ function BrowserProfilesList({ searchKey }: Props = {}) { } = useRowSelection({ items: pageItems, getId: (profile) => profile.browser_profile_id, - resetKey: JSON.stringify([page, itemsPerPage, searchKey ?? ""]), + resetKey: JSON.stringify([page, itemsPerPage, searchKey ?? "", managed]), }); async function handleBulkDeleteConfirm() { @@ -211,6 +214,19 @@ function BrowserProfilesList({ searchKey }: Props = {}) {
{hasSearch ? ( <>No browser profiles match “{searchKey}”. + ) : managed === true ? ( +
+ +
+

+ No auto-managed profiles yet +

+

+ Run a workflow with Save & Reuse Session enabled and Skyvern + creates one automatically. +

+
+
) : (
diff --git a/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesPage.tsx b/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesPage.tsx index 545e9fb3e..9b5db5747 100644 --- a/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesPage.tsx +++ b/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesPage.tsx @@ -16,6 +16,16 @@ function BrowserProfilesPage() { const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebounce(search, 250); + const [profileType, setProfileType] = useState<"all" | "user" | "managed">( + "all", + ); + const managed = profileType === "all" ? undefined : profileType === "managed"; + + function resetToFirstPage() { + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + setSearchParams(params, { replace: true }); + } // Mounted for its side effects: rehydrates an in-progress create from // sessionStorage so the toast still fires if the user navigates here from @@ -34,20 +44,33 @@ function BrowserProfilesPage() {

- { - setSearch(value); - const params = new URLSearchParams(searchParams); - params.set("page", "1"); - setSearchParams(params, { replace: true }); - }} - placeholder="Search browser profiles..." - className="w-48 lg:w-72" - /> +
+ { + setSearch(value); + resetToFirstPage(); + }} + placeholder="Search browser profiles..." + className="w-48 lg:w-72" + /> + +
- +
); } diff --git a/skyvern-frontend/src/routes/browserProfiles/CreateBrowserProfileButton.tsx b/skyvern-frontend/src/routes/browserProfiles/CreateBrowserProfileButton.tsx index 6a111b76e..d5851b1a6 100644 --- a/skyvern-frontend/src/routes/browserProfiles/CreateBrowserProfileButton.tsx +++ b/skyvern-frontend/src/routes/browserProfiles/CreateBrowserProfileButton.tsx @@ -1,6 +1,18 @@ +import { useState } from "react"; import { PlusIcon, ReloadIcon } from "@radix-ui/react-icons"; +import { PINNED_RESIDENTIAL_ISP_PROXY_LOCATION } from "@/api/types"; +import { HelpTooltip } from "@/components/HelpTooltip"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; import { useCreateBrowserSessionMutation } from "@/routes/browserSessions/hooks/useCreateBrowserSessionMutation"; import { useBrowserProfileCreateStore } from "@/store/useBrowserProfileCreateStore"; @@ -13,6 +25,8 @@ function CreateBrowserProfileButton({ size = "default", label = "Create a Browser Profile", }: Props) { + const [open, setOpen] = useState(false); + const [pinResidentialIspProxy, setPinResidentialIspProxy] = useState(false); const createBrowserSessionMutation = useCreateBrowserSessionMutation(); const isBackgroundCreateInProgress = useBrowserProfileCreateStore( (state) => state.active !== null, @@ -21,30 +35,83 @@ function CreateBrowserProfileButton({ const disabled = createBrowserSessionMutation.isPending || isBackgroundCreateInProgress; + const handleCreate = () => { + createBrowserSessionMutation.mutate({ + proxyLocation: pinResidentialIspProxy + ? PINNED_RESIDENTIAL_ISP_PROXY_LOCATION + : null, + proxySessionId: null, + timeout: null, + generateBrowserProfile: true, + }); + setOpen(false); + }; + return ( - + <> + + + event.preventDefault()}> + + Create Browser Profile + +
+
+ + setPinResidentialIspProxy(checked === true) + } + /> +
+
+ + +
+

+ Skyvern will create a residential IP identity for this + profile. +

+
+
+
+ + + + +
+
+ ); } diff --git a/skyvern-frontend/src/routes/browserProfiles/hooks/useBrowserProfileMutations.test.tsx b/skyvern-frontend/src/routes/browserProfiles/hooks/useBrowserProfileMutations.test.tsx new file mode 100644 index 000000000..51e1b92d4 --- /dev/null +++ b/skyvern-frontend/src/routes/browserProfiles/hooks/useBrowserProfileMutations.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PINNED_RESIDENTIAL_ISP_PROXY_LOCATION } from "@/api/types"; + +const { mockPatch } = vi.hoisted(() => ({ mockPatch: vi.fn() })); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: () => Promise.resolve({ patch: mockPatch }), +})); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => () => Promise.resolve("test-token"), +})); + +vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() })); + +import { useUpdateBrowserProfileMutation } from "./useBrowserProfileMutations"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ( + {children} + ); +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useUpdateBrowserProfileMutation proxy pin payloads", () => { + it("omits proxy_session_id when keeping the existing consistent IP identity", async () => { + mockPatch.mockResolvedValue({ data: {} }); + const { result } = renderHook(() => useUpdateBrowserProfileMutation(), { + wrapper, + }); + + act(() => { + result.current.mutate({ + profileId: "bp_123", + proxy_location: PINNED_RESIDENTIAL_ISP_PROXY_LOCATION, + proxy_session_id: undefined, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockPatch).toHaveBeenCalledWith("/browser_profiles/bp_123", { + proxy_location: PINNED_RESIDENTIAL_ISP_PROXY_LOCATION, + }); + }); + + it("sends rotate_proxy_session_id when rotating the consistent IP identity", async () => { + mockPatch.mockResolvedValue({ data: {} }); + const { result } = renderHook(() => useUpdateBrowserProfileMutation(), { + wrapper, + }); + + act(() => { + result.current.mutate({ + profileId: "bp_123", + proxy_location: PINNED_RESIDENTIAL_ISP_PROXY_LOCATION, + rotate_proxy_session_id: true, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockPatch).toHaveBeenCalledWith("/browser_profiles/bp_123", { + proxy_location: PINNED_RESIDENTIAL_ISP_PROXY_LOCATION, + rotate_proxy_session_id: true, + }); + }); + + it("sends null proxy fields when disabling consistent IP", async () => { + mockPatch.mockResolvedValue({ data: {} }); + const { result } = renderHook(() => useUpdateBrowserProfileMutation(), { + wrapper, + }); + + act(() => { + result.current.mutate({ + profileId: "bp_123", + proxy_location: null, + proxy_session_id: null, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockPatch).toHaveBeenCalledWith("/browser_profiles/bp_123", { + proxy_location: null, + proxy_session_id: null, + }); + }); +}); diff --git a/skyvern-frontend/src/routes/browserProfiles/hooks/useBrowserProfileMutations.ts b/skyvern-frontend/src/routes/browserProfiles/hooks/useBrowserProfileMutations.ts index f92758480..a678a0900 100644 --- a/skyvern-frontend/src/routes/browserProfiles/hooks/useBrowserProfileMutations.ts +++ b/skyvern-frontend/src/routes/browserProfiles/hooks/useBrowserProfileMutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; import { getClient } from "@/api/AxiosClient"; -import { BrowserProfileApiResponse } from "@/api/types"; +import { BrowserProfileApiResponse, ProxyLocation } from "@/api/types"; import { toast } from "@/components/ui/use-toast"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; @@ -10,6 +10,9 @@ type UpdateBrowserProfileInput = { profileId: string; name?: string; description?: string | null; + proxy_location?: ProxyLocation | null; + proxy_session_id?: string | null; + rotate_proxy_session_id?: boolean; }; function useUpdateBrowserProfileMutation() { @@ -21,15 +24,28 @@ function useUpdateBrowserProfileMutation() { profileId, name, description, + proxy_location, + proxy_session_id, + rotate_proxy_session_id, }: UpdateBrowserProfileInput) => { const client = await getClient(credentialGetter, "sans-api-v1"); - const payload: Record = {}; + const payload: Record = + {}; if (name !== undefined) { payload.name = name; } if (description !== undefined) { payload.description = description; } + if (proxy_location !== undefined) { + payload.proxy_location = proxy_location; + } + if (proxy_session_id !== undefined) { + payload.proxy_session_id = proxy_session_id; + } + if (rotate_proxy_session_id !== undefined) { + payload.rotate_proxy_session_id = rotate_proxy_session_id; + } return client .patch( `/browser_profiles/${profileId}`, diff --git a/skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx index 93c9c2b86..9bd5252c1 100644 --- a/skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx +++ b/skyvern-frontend/src/routes/browserSessions/BrowserSession.tsx @@ -34,6 +34,7 @@ import { import { getBrowserSessionRefetchIntervalMs } from "./browserSessionQueryUtils"; import { BrowserSessionDownloads } from "./BrowserSessionDownloads"; +import { BrowserSessionOccupiedBy } from "./BrowserSessionOccupiedBy"; import { BrowserSessionVideo } from "./BrowserSessionVideo"; import { BrowserSessionStream } from "./BrowserSessionStream"; import { BrowserSessionWorkflowRuns } from "./BrowserSessionWorkflowRuns"; @@ -157,6 +158,11 @@ function BrowserSession() { /> )} + {browserSession.runnable_id && ( + + )} )} diff --git a/skyvern-frontend/src/routes/browserSessions/BrowserSessionOccupiedBy.test.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSessionOccupiedBy.test.tsx new file mode 100644 index 000000000..57d9deae9 --- /dev/null +++ b/skyvern-frontend/src/routes/browserSessions/BrowserSessionOccupiedBy.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; + +import { BrowserSessionOccupiedBy } from "./BrowserSessionOccupiedBy"; + +vi.mock("@/routes/workflows/editor/Workspace", () => ({ + CopyText: () => null, +})); + +afterEach(cleanup); + +function renderBadge(runnableId: string) { + render( + + + , + ); +} + +describe("BrowserSessionOccupiedBy", () => { + it("links a workflow run to its run detail page", () => { + renderBadge("wr_123"); + expect(screen.getByText("In use by")).toBeTruthy(); + const link = screen.getByRole("link", { name: "wr_123" }); + expect(link.getAttribute("href")).toBe("/runs/wr_123"); + }); + + it("links a task run to its run detail page", () => { + renderBadge("tsk_456"); + const link = screen.getByRole("link", { name: "tsk_456" }); + expect(link.getAttribute("href")).toBe("/runs/tsk_456"); + }); +}); diff --git a/skyvern-frontend/src/routes/browserSessions/BrowserSessionOccupiedBy.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSessionOccupiedBy.tsx new file mode 100644 index 000000000..c67d7c55b --- /dev/null +++ b/skyvern-frontend/src/routes/browserSessions/BrowserSessionOccupiedBy.tsx @@ -0,0 +1,20 @@ +import { Link } from "react-router-dom"; + +import { CopyText } from "@/routes/workflows/editor/Workspace"; + +function BrowserSessionOccupiedBy({ runnableId }: { runnableId: string }) { + return ( +
+ In use by + + {runnableId} + + +
+ ); +} + +export { BrowserSessionOccupiedBy }; diff --git a/skyvern-frontend/src/routes/browserSessions/BrowserSessionStream.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSessionStream.tsx index fcea06d3e..d824523bb 100644 --- a/skyvern-frontend/src/routes/browserSessions/BrowserSessionStream.tsx +++ b/skyvern-frontend/src/routes/browserSessions/BrowserSessionStream.tsx @@ -95,14 +95,20 @@ interface Props { browserSessionId: string; interactive?: boolean; showControlButtons?: boolean; + centered?: boolean; onReadyChange?: (isReady: boolean, browserSessionId: string | null) => void; + onUrlChange?: (url: string) => void; + onActivity?: () => void; } function BrowserSessionStream({ browserSessionId, interactive = false, showControlButtons = false, + centered = false, onReadyChange, + onUrlChange, + onActivity, }: Props) { const [streamImgSrc, setStreamImgSrc] = useState(""); const [streamFormat, setStreamFormat] = useState("png"); @@ -115,12 +121,17 @@ function BrowserSessionStream({ const settingsStore = useSettingsStore(); const socketRef = useRef(null); + const onActivityRef = useRef(onActivity); const hasFrameRef = useRef(false); const reconnectAttemptsRef = useRef(0); const terminalStatusSeenRef = useRef(false); const reconnectTimerRef = useRef | null>(null); - const inputWsUrl = interactive + // The CDP input socket must be wired whenever the stream can be controlled, + // whether by default interaction or via the take-control button. + const controllable = interactive || showControlButtons; + + const inputWsUrl = controllable ? `${newWssBaseUrl}/stream/cdp_input/browser_session/${browserSessionId}` : null; @@ -132,11 +143,23 @@ function BrowserSessionStream({ handlers, } = useCdpInput({ inputWsUrl, - interactive, + interactive: controllable, viewportWidth, viewportHeight, }); + useEffect(() => { + onActivityRef.current = onActivity; + }, [onActivity]); + + // Once control can't be offered (input socket torn down), forget any prior + // grab so re-enabling doesn't silently restore control without a new click. + useEffect(() => { + if (!controllable) { + setUserIsControlling(false); + } + }, [controllable, setUserIsControlling]); + useEffect(() => { let cancelled = false; setStreamImgSrc(""); @@ -180,6 +203,8 @@ function BrowserSessionStream({ socketRef.current.addEventListener("message", (event) => { try { const message: StreamMessage = JSON.parse(event.data); + const hasActivity = + Boolean(message.screenshot) || message.url !== undefined; if (message.screenshot) { hasFrameRef.current = true; reconnectAttemptsRef.current = 0; @@ -197,6 +222,9 @@ function BrowserSessionStream({ if (message.url !== undefined) { setCurrentUrl(message.url); } + if (hasActivity) { + onActivityRef.current?.(); + } if (!message.screenshot && message.status) { setDiagnostic(diagnosticForStatus(message.status)); } @@ -278,6 +306,10 @@ function BrowserSessionStream({ const isReady = streamImgSrc.length > 0; + useEffect(() => { + onUrlChange?.(currentUrl); + }, [currentUrl, onUrlChange]); + useEffect(() => { // browserSessionId intentionally not a dep: re-firing on prop change // before isReady resets would spuriously report (true, newSessionId). @@ -304,7 +336,7 @@ function BrowserSessionStream({ ); } diff --git a/skyvern-frontend/src/routes/browserSessions/BrowserSessionWorkflowRuns.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSessionWorkflowRuns.tsx index 5c77c121e..934374aea 100644 --- a/skyvern-frontend/src/routes/browserSessions/BrowserSessionWorkflowRuns.tsx +++ b/skyvern-frontend/src/routes/browserSessions/BrowserSessionWorkflowRuns.tsx @@ -101,7 +101,7 @@ function BrowserSessionWorkflowRuns() { {runs.map((workflowRun) => { const url = env.useNewRunsUrl ? `/runs/${workflowRun.workflow_run_id}` - : `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/overview`; + : `/agents/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/overview`; return ( ({ + mockNavigate: vi.fn(), + mockPost: vi.fn(), +})); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: () => Promise.resolve({ post: mockPost }), +})); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => () => Promise.resolve("test-token"), +})); + +vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() })); + +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +import { useCreateBrowserSessionMutation } from "./useCreateBrowserSessionMutation"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ( + + {children} + + ); +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useCreateBrowserSessionMutation proxy pin payloads", () => { + it("sends null proxy_session_id when creating a pinned profile", async () => { + mockPost.mockResolvedValue({ + data: { browser_session_id: "pbs_123" }, + }); + const { result } = renderHook(() => useCreateBrowserSessionMutation(), { + wrapper, + }); + + act(() => { + result.current.mutate({ + proxyLocation: PINNED_RESIDENTIAL_ISP_PROXY_LOCATION, + proxySessionId: null, + timeout: null, + generateBrowserProfile: true, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockPost).toHaveBeenCalledWith("/browser_sessions", { + proxy_location: PINNED_RESIDENTIAL_ISP_PROXY_LOCATION, + proxy_session_id: null, + timeout: null, + extensions: [], + browser_type: null, + generate_browser_profile: true, + }); + expect(mockNavigate).toHaveBeenCalledWith("/browser-session/pbs_123"); + }); +}); diff --git a/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts b/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts index dc1526ee6..5027dd985 100644 --- a/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts +++ b/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts @@ -19,12 +19,14 @@ function useCreateBrowserSessionMutation() { return useMutation({ mutationFn: async ({ proxyLocation = null, + proxySessionId = null, timeout = null, extensions = [], browserType = null, generateBrowserProfile = false, }: { proxyLocation: ProxyLocation | null; + proxySessionId?: string | null; timeout: number | null; extensions?: BrowserSessionExtension[]; browserType?: BrowserSessionType | null; @@ -35,6 +37,7 @@ function useCreateBrowserSessionMutation() { "/browser_sessions", { proxy_location: proxyLocation, + proxy_session_id: proxySessionId, timeout, extensions, browser_type: browserType, diff --git a/skyvern-frontend/src/routes/browserSessions/hooks/useProcessRecordingMutation.ts b/skyvern-frontend/src/routes/browserSessions/hooks/useProcessRecordingMutation.ts index 995cb4eb8..10a1178f1 100644 --- a/skyvern-frontend/src/routes/browserSessions/hooks/useProcessRecordingMutation.ts +++ b/skyvern-frontend/src/routes/browserSessions/hooks/useProcessRecordingMutation.ts @@ -5,7 +5,10 @@ import { useRef } from "react"; import { getClient } from "@/api/AxiosClient"; import { toast } from "@/components/ui/use-toast"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; -import { useRecordingStore } from "@/store/useRecordingStore"; +import { + useRecordingStore, + type RecordingDraftStep, +} from "@/store/useRecordingStore"; import { type WorkflowBlock, type WorkflowParameter, @@ -33,7 +36,18 @@ const useProcessRecordingMutation = ({ const mutationStartedAtRef = useRef(null); const processRecordingMutation = useMutation({ - mutationFn: async () => { + mutationFn: async ( + variables: { + /** + * Live-interpreted draft steps (with user edits/deletes applied). + * When provided the backend converts them deterministically instead + * of re-processing the raw event stream. + */ + draftSteps?: Array | null; + } | void, + ) => { + const draftSteps = variables?.draftSteps ?? null; + if (!browserSessionId) { throw new Error( "Cannot process recording without a valid browser session ID.", @@ -49,8 +63,9 @@ const useProcessRecordingMutation = ({ mutationStartedAtRef.current = Date.now(); const eventCount = recordingStore.getEventCount(); + const hasDraftSteps = (draftSteps?.length ?? 0) > 0; - if (eventCount === 0) { + if (eventCount === 0 && !hasDraftSteps) { captureRecordBrowser("record_browser.empty_blocked", { seconds_recording: recordingStore.getSecondsRecording(), }); @@ -62,12 +77,16 @@ const useProcessRecordingMutation = ({ captureRecordBrowser("record_browser.process_attempted", { event_count: recordingStore.getEventCount(), compressed_chunk_count: compressedChunks.length, + draft_step_count: draftSteps?.length, }); const client = await getClient(credentialGetter, "sans-api-v1"); return client .post< - { compressed_chunks: string[] }, + { + compressed_chunks: string[]; + draft_steps?: Array; + }, { data: { blocks: Array; @@ -77,6 +96,7 @@ const useProcessRecordingMutation = ({ >(`/browser_sessions/${browserSessionId}/process_recording`, { compressed_chunks: compressedChunks, workflow_permanent_id: workflowPermanentId, + ...(draftSteps !== null ? { draft_steps: draftSteps } : {}), }) .then((response) => ({ blocks: response.data.blocks, @@ -112,6 +132,11 @@ const useProcessRecordingMutation = ({ return; } + // A zero-block commit still ends the session: the caller's onSuccess (which + // normally exits recording after landing blocks) is skipped, and without + // this the user is stranded in the recording panel with a dead Done button. + recordingStore.setIsRecording(false); + toast({ variant: "warning", title: "Recording Processed (No Blocks)", @@ -141,6 +166,8 @@ const useProcessRecordingMutation = ({ latency_ms: latencyMs, }); + useRecordingStore.setState({ finishRequested: false }); + toast({ variant: "destructive", title: "Error Processing Recording", diff --git a/skyvern-frontend/src/routes/credentials/AuthenticatorAppLogo.tsx b/skyvern-frontend/src/routes/credentials/AuthenticatorAppLogo.tsx new file mode 100644 index 000000000..2d27ce79b --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/AuthenticatorAppLogo.tsx @@ -0,0 +1,12 @@ +function AuthenticatorAppLogo({ src }: { src: string }) { + return ( + + ); +} + +export { AuthenticatorAppLogo }; diff --git a/skyvern-frontend/src/routes/credentials/BitwardenCredentialsList.tsx b/skyvern-frontend/src/routes/credentials/BitwardenCredentialsList.tsx new file mode 100644 index 000000000..87f5e74bb --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/BitwardenCredentialsList.tsx @@ -0,0 +1,128 @@ +import { BitwardenIcon } from "@/components/icons/BitwardenIcon"; +import { getHostname } from "@/util/getHostname"; +import { useBitwardenItemsQuery } from "@/routes/workflows/hooks/useBitwardenItemsQuery"; + +type Props = { + search?: string; + folderId?: string | null; +}; + +function BitwardenCredentialsList({ search, folderId }: Props) { + const { data, isLoading, isError } = useBitwardenItemsQuery(); + const normalizedSearch = search?.trim().toLowerCase() ?? ""; + const muted = "text-sm text-neutral-600 dark:text-slate-400"; + const filteredItems = (data?.items ?? []).filter( + (item) => + item.credential_type === "password" && + (!normalizedSearch || + item.title.toLowerCase().includes(normalizedSearch)), + ); + const hasNonPasswordItems = (data?.items ?? []).some( + (item) => item.credential_type !== "password", + ); + + const header = ( +
+ +
+

Bitwarden

+

Read-only — managed in your Bitwarden account

+
+
+ ); + + if (folderId || isLoading) { + return null; + } + + if (isError) { + return ( +
+ {header} +
+ Couldn't load Bitwarden items. +
+
+ ); + } + + if (!data?.configured) { + return null; + } + + if (filteredItems.length === 0) { + if (!hasNonPasswordItems) { + return null; + } + return ( +
+ {header} +

+ Credit cards and secrets are available in the workflow editor. +

+
+ ); + } + + return ( +
+ {header} +
+ {filteredItems.map((item) => { + const rows = [ + item.url && { + label: "Website", + value: getHostname(item.url) ?? item.url, + }, + item.collection_id && { + label: "Collection ID", + value: item.collection_id, + }, + { label: "Item ID", value: item.item_id }, + ].filter(Boolean) as Array<{ label: string; value: string }>; + + return ( +
+
+ +
+

+ {item.title} +

+ {item.collection_id && ( +

+ {item.collection_id} +

+ )} +
+
+
+ {rows.map((row) => ( +
+

{row.label}

+

+ {row.value} +

+
+ ))} +
+
+ ); + })} +
+ {hasNonPasswordItems && ( +

+ Credit cards and secrets are available in the workflow editor. +

+ )} +
+ ); +} + +export { BitwardenCredentialsList }; diff --git a/skyvern-frontend/src/routes/credentials/CredentialAuthenticatorSupportContext.ts b/skyvern-frontend/src/routes/credentials/CredentialAuthenticatorSupportContext.ts new file mode 100644 index 000000000..906bbf294 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialAuthenticatorSupportContext.ts @@ -0,0 +1,58 @@ +import { + createContext, + createElement, + useContext, + type ReactNode, +} from "react"; + +type CredentialEnterpriseAuthenticatorSupport = { + label: string; + apps: string[]; + description: ReactNode; + contactUrl?: string; + vendorLabels?: Record; + qrCodeTypes?: CredentialAuthenticatorQrCodeType[]; + inferQrCodeType?: (value: string) => string | null; +}; + +type CredentialAuthenticatorQrCodeType = { + id: string; + label: string; + description?: ReactNode; + logo?: ReactNode; +}; + +type CredentialAuthenticatorSupportCopy = { + enterpriseApps?: CredentialEnterpriseAuthenticatorSupport; +}; + +const CredentialAuthenticatorSupportContext = + createContext({}); + +function CredentialAuthenticatorSupportProvider({ + children, + value, +}: { + children: ReactNode; + value: CredentialAuthenticatorSupportCopy; +}) { + return createElement( + CredentialAuthenticatorSupportContext.Provider, + { value }, + children, + ); +} + +function useCredentialAuthenticatorSupport() { + return useContext(CredentialAuthenticatorSupportContext); +} + +export { + CredentialAuthenticatorSupportProvider, + useCredentialAuthenticatorSupport, +}; +export type { + CredentialAuthenticatorSupportCopy, + CredentialAuthenticatorQrCodeType, + CredentialEnterpriseAuthenticatorSupport, +}; diff --git a/skyvern-frontend/src/routes/credentials/CredentialItem.test.tsx b/skyvern-frontend/src/routes/credentials/CredentialItem.test.tsx new file mode 100644 index 000000000..bc6a62e52 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialItem.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getClient } from "@/api/AxiosClient"; +import type { CredentialApiResponse } from "@/api/types"; +import { copyText } from "@/util/copyText"; +import { CredentialItem } from "./CredentialItem"; + +vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() })); +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); +vi.mock("@/store/useCredentialTestStore", () => ({ + useCredentialTestStore: ( + selector: (state: { activeTest: null }) => unknown, + ) => selector({ activeTest: null }), +})); +vi.mock("@/util/copyText", () => ({ copyText: vi.fn() })); +vi.mock("@/components/ui/use-toast", () => ({ + toast: vi.fn(), +})); +vi.mock("./CredentialFolderSelector", () => ({ + CredentialFolderSelector: () => , +})); +vi.mock("./DeleteCredentialButton", () => ({ + DeleteCredentialButton: () => , +})); +vi.mock("./CredentialsModal", () => ({ + CredentialsModal: () => null, +})); + +const mockedGetClient = vi.mocked(getClient); +const mockedCopyText = vi.mocked(copyText); + +function makePasswordCredential( + overrides: Partial = {}, +): CredentialApiResponse { + return { + credential_id: "cred_1", + credential_type: "password", + name: "Example Login", + credential: { + username: "user@example.com", + totp_type: "authenticator", + totp_identifier: null, + }, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("CredentialItem TOTP code preview", () => { + it("loads and copies the current authenticator code on demand", async () => { + mockedGetClient.mockResolvedValue({ + get: vi.fn().mockResolvedValue({ + data: { code: "123456", seconds_remaining: 24 }, + }), + } as never); + mockedCopyText.mockResolvedValue(true); + + render(); + + expect(screen.getAllByText("••••••••").length).toBeGreaterThan(1); + + fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" })); + + await waitFor(() => { + expect(screen.getByText("123 456")).toBeTruthy(); + expect(screen.getByText("24s")).toBeTruthy(); + }); + expect(mockedGetClient).toHaveBeenCalledWith(null, "sans-api-v1"); + + fireEvent.click(screen.getByRole("button", { name: "Copy 2FA code" })); + + await waitFor(() => { + expect(mockedCopyText).toHaveBeenCalledWith("123456"); + }); + }); + + it("shows an API error when the saved authenticator code cannot be loaded", async () => { + mockedGetClient.mockResolvedValue({ + get: vi.fn().mockRejectedValue({ + isAxiosError: true, + response: { + data: { + detail: { + error_code: "invalid_authenticator_key", + message: "Saved authenticator key is invalid.", + }, + }, + }, + }), + } as never); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" })); + + await waitFor(() => { + expect( + screen.getByText("Saved authenticator key is invalid."), + ).toBeTruthy(); + }); + }); + + it("refreshes a hidden authenticator code without revealing it", async () => { + const getMock = vi + .fn() + .mockResolvedValueOnce({ + data: { code: "123456", seconds_remaining: 24 }, + }) + .mockResolvedValueOnce({ + data: { code: "654321", seconds_remaining: 20 }, + }); + mockedGetClient.mockResolvedValue({ get: getMock } as never); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" })); + await waitFor(() => { + expect(screen.getByText("123 456")).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Hide 2FA code" })); + expect(screen.queryByText("123 456")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Refresh 2FA code" })); + await waitFor(() => { + expect(getMock).toHaveBeenCalledTimes(2); + }); + expect(screen.queryByText("654 321")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" })); + expect(screen.getByText("654 321")).toBeTruthy(); + expect(getMock).toHaveBeenCalledTimes(2); + }); + + it("clears the displayed code after it expires", async () => { + mockedGetClient.mockResolvedValue({ + get: vi.fn().mockResolvedValue({ + data: { code: "123456", seconds_remaining: 1 }, + }), + } as never); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" })); + + await waitFor(() => { + expect(screen.getByText("123 456")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Copy 2FA code" }), + ).toBeTruthy(); + }); + + await waitFor( + () => { + expect(screen.queryByText("123 456")).toBeNull(); + const copyButton = screen.getByRole("button", { + name: "Copy 2FA code", + }) as HTMLButtonElement; + expect(copyButton.disabled).toBe(true); + expect( + screen.getByText("2FA code expired. Refresh to load a new code."), + ).toBeTruthy(); + }, + { timeout: 3000 }, + ); + }); +}); diff --git a/skyvern-frontend/src/routes/credentials/CredentialItem.tsx b/skyvern-frontend/src/routes/credentials/CredentialItem.tsx index b63c24a5a..c599350b8 100644 --- a/skyvern-frontend/src/routes/credentials/CredentialItem.tsx +++ b/skyvern-frontend/src/routes/credentials/CredentialItem.tsx @@ -1,12 +1,17 @@ import { - CredentialApiResponse, isCreditCardCredential, isPasswordCredential, isSecretCredential, + type CredentialApiResponse, + type CredentialTotpCodeResponse, } from "@/api/types"; +import { getClient } from "@/api/AxiosClient"; import { SelectionCheckbox } from "@/components/SelectionCheckbox"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { + CopyIcon, + EyeNoneIcon, + EyeOpenIcon, ExclamationTriangleIcon, ExternalLinkIcon, Pencil1Icon, @@ -26,6 +31,11 @@ import { CredentialsModal } from "./CredentialsModal"; import { credentialTypeToModalType } from "./useCredentialModalState"; import { SaveIcon } from "@/components/icons/SaveIcon"; import { useCredentialTestStore } from "@/store/useCredentialTestStore"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { toast } from "@/components/ui/use-toast"; +import { copyText } from "@/util/copyText"; +import { cn } from "@/util/utils"; +import { getCredentialErrorMessage } from "./authenticatorSaveError"; type Props = { credential: CredentialApiResponse; @@ -40,6 +50,205 @@ type Props = { onSelect?: (index: number, shiftKey: boolean) => void; }; +function formatTotpCode(code: string): string { + const splitAt = Math.ceil(code.length / 2); + return `${code.slice(0, splitAt)} ${code.slice(splitAt)}`; +} + +function CredentialTotpCodePreview({ credentialId }: { credentialId: string }) { + const credentialGetter = useCredentialGetter(); + const isMountedRef = useRef(true); + const [totpCode, setTotpCode] = useState( + null, + ); + const [secondsRemaining, setSecondsRemaining] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); + const [isCodeVisible, setIsCodeVisible] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (expiresAt === null) { + return; + } + + const updateRemaining = () => { + const nextSecondsRemaining = Math.max( + 0, + Math.ceil((expiresAt - Date.now()) / 1000), + ); + if (nextSecondsRemaining <= 0) { + setTotpCode(null); + setSecondsRemaining(null); + setExpiresAt(null); + setIsCodeVisible(false); + setError("2FA code expired. Refresh to load a new code."); + return; + } + setSecondsRemaining(nextSecondsRemaining); + }; + + updateRemaining(); + const interval = window.setInterval(updateRemaining, 1000); + return () => window.clearInterval(interval); + }, [expiresAt]); + + const fetchTotpCode = async ({ + reveal = true, + }: { reveal?: boolean } = {}) => { + setIsLoading(true); + setError(null); + try { + const client = await getClient(credentialGetter, "sans-api-v1"); + const response = await client.get( + `/credentials/${credentialId}/totp-code`, + ); + if (!isMountedRef.current) { + return; + } + setTotpCode(response.data); + setSecondsRemaining(response.data.seconds_remaining); + setExpiresAt(Date.now() + response.data.seconds_remaining * 1000); + setIsCodeVisible(reveal); + } catch (caught) { + if (!isMountedRef.current) { + return; + } + setTotpCode(null); + setSecondsRemaining(null); + setExpiresAt(null); + setIsCodeVisible(false); + setError(getCredentialErrorMessage(caught) ?? "Unable to load code"); + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } + }; + + const hasValidTotpCode = + totpCode !== null && secondsRemaining !== null && secondsRemaining > 0; + const visibleTotpCode = + isCodeVisible && hasValidTotpCode ? totpCode.code : null; + const canCopyCode = Boolean(visibleTotpCode); + + const handleToggleCodeVisibility = () => { + if (isCodeVisible) { + setIsCodeVisible(false); + return; + } + + if (hasValidTotpCode) { + setIsCodeVisible(true); + return; + } + + void fetchTotpCode(); + }; + + const handleCopyCode = async () => { + if (!visibleTotpCode) { + return; + } + // Display formatting adds spacing; copy the raw code for paste targets. + const copied = await copyText(visibleTotpCode); + toast({ + title: copied ? "Copied code" : "Copy failed", + description: copied + ? "The current 2FA code was copied to your clipboard." + : "Could not copy the current 2FA code.", + variant: copied ? "success" : "destructive", + }); + }; + + return ( +
+
+ + {isCodeVisible && totpCode + ? formatTotpCode(totpCode.code) + : "••••••••"} + + + {secondsRemaining ?? 0}s + + + + + + + + {isCodeVisible ? "Hide 2FA code" : "Show 2FA code"} + + + + + + + Refresh 2FA code + + + + + + Copy 2FA code + + +
+ {error &&

{error}

} +
+ ); +} + function CredentialItem({ credential, onStartBackgroundTest, @@ -105,15 +314,25 @@ function CredentialItem({ 2FA Type

)} + {credentialData.totp_type === "authenticator" && ( +

+ 2FA Code +

+ )}

{credentialData.username}

-

{"********"}

+

{"••••••••"}

{credentialData.totp_type !== "none" && ( -

+

{getTotpTypeDisplay(credentialData.totp_type)}

)} + {credentialData.totp_type === "authenticator" && ( + + )}
diff --git a/skyvern-frontend/src/routes/credentials/CredentialsModal.test.tsx b/skyvern-frontend/src/routes/credentials/CredentialsModal.test.tsx new file mode 100644 index 000000000..d7682d6c7 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialsModal.test.tsx @@ -0,0 +1,332 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + fireEvent, + render, + screen, + waitFor, + cleanup, +} from "@testing-library/react"; +import { AxiosError, AxiosHeaders } from "axios"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { CredentialApiResponse } from "@/api/types"; + +import { getAuthenticatorKeyError } from "./credentialTotpValidation"; +import { CredentialsModal } from "./CredentialsModal"; +import { CredentialModalTypes } from "./useCredentialModalState"; + +const postMock = vi.hoisted(() => vi.fn()); +const patchMock = vi.hoisted(() => vi.fn()); +const deleteMock = vi.hoisted(() => vi.fn()); +const getMock = vi.hoisted(() => vi.fn()); +const toastMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: vi.fn(async () => ({ + post: postMock, + patch: patchMock, + delete: deleteMock, + get: getMock, + })), +})); + +vi.mock("@/components/ui/use-toast", () => ({ + toast: toastMock, +})); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); + +vi.mock("@/hooks/useCustomCredentialServiceConfig", () => ({ + useCustomCredentialServiceConfig: () => ({ parsedConfig: null }), +})); + +vi.mock("@/routes/workflows/hooks/useCredentialsQuery", () => ({ + useCredentialsQuery: () => ({ data: [] }), +})); + +function axiosErrorWithDetail(detail: unknown): AxiosError { + const error = new AxiosError("Request failed"); + error.response = { + data: { detail }, + status: 400, + statusText: "Bad Request", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + return error; +} + +function renderPasswordCredentialsModal() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render( + + + + + , + ); +} + +const editingPasswordCredential: CredentialApiResponse = { + credential_id: "real-cred-id", + credential_type: "password", + name: "Acme Login", + credential: { + username: "user@example.com", + totp_type: "none", + totp_identifier: null, + }, + browser_profile_id: "existing-profile-id", + tested_url: "https://example.com/login", + user_context: null, + save_browser_session_intent: true, + folder_id: null, + proxy_location: null, + proxy_session_id: null, +}; + +function renderEditPasswordCredentialsModal() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render( + + + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("getAuthenticatorKeyError", () => { + it("requires an authenticator key when authenticator 2FA is selected", () => { + expect( + getAuthenticatorKeyError({ totp: " ", totp_type: "authenticator" }), + ).toBe("Authenticator key is required."); + }); + + it("lets backend validation decide authenticator key format", () => { + expect( + getAuthenticatorKeyError({ + totp: "provider-specific-payload", + totp_type: "authenticator", + }), + ).toBeNull(); + }); + + it("accepts a raw Base32 key or a full otpauth URI", () => { + expect( + getAuthenticatorKeyError({ + totp: "JBSW-Y3DP EHPK3PXP", + totp_type: "authenticator", + }), + ).toBeNull(); + expect( + getAuthenticatorKeyError({ + totp: "otpauth://totp/user@example.com?secret=JBSWY3DPEHPK3PXP", + totp_type: "authenticator", + }), + ).toBeNull(); + }); + + it("does not validate the key for email, text, or disabled 2FA methods", () => { + expect( + getAuthenticatorKeyError({ totp: "", totp_type: "email" }), + ).toBeNull(); + expect( + getAuthenticatorKeyError({ totp: "", totp_type: "text" }), + ).toBeNull(); + expect( + getAuthenticatorKeyError({ totp: "", totp_type: "none" }), + ).toBeNull(); + }); +}); + +describe("CredentialsModal authenticator save errors", () => { + async function fillAndSubmitAuthenticatorCredential(totp: string) { + renderPasswordCredentialsModal(); + + await waitFor(() => { + expect(screen.getByDisplayValue("credentials")).toBeTruthy(); + }); + const usernameInput = Array.from( + document.querySelectorAll("input"), + ).find( + (input) => + input.type === "text" && input.value === "" && input.placeholder === "", + ); + expect(usernameInput).toBeTruthy(); + fireEvent.change(usernameInput as HTMLInputElement, { + target: { value: "user@example.com" }, + }); + const passwordInput = document.querySelector('input[type="password"]'); + expect(passwordInput).toBeTruthy(); + fireEvent.change(passwordInput as HTMLInputElement, { + target: { value: "password" }, + }); + + fireEvent.click(screen.getByText("Two-Factor Authentication")); + const authenticatorInput = screen.getByPlaceholderText( + "e.g. JBSWY3DPEHPK3PXP", + ); + fireEvent.change(authenticatorInput, { + target: { value: totp }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + return authenticatorInput as HTMLInputElement; + } + + it("shows a rejected authenticator QR inline and keeps the submitted value in the field", async () => { + postMock.mockRejectedValueOnce( + axiosErrorWithDetail({ + error_code: "authenticator_no_code_secret", + message: "This QR code enrolls push approval.", + vendor: "microsoft", + }), + ); + + const decodedQrPayload = "phonefactor://activate_account?code=123456"; + const authenticatorInput = + await fillAndSubmitAuthenticatorCredential(decodedQrPayload); + + await waitFor(() => { + expect(postMock).toHaveBeenCalledWith( + "/credentials", + expect.objectContaining({ + credential: expect.objectContaining({ + totp: decodedQrPayload, + totp_type: "authenticator", + }), + }), + ); + }); + await waitFor(() => { + expect(screen.getByText(/push-approval app/)).toBeTruthy(); + }); + expect((authenticatorInput as HTMLInputElement).value).toBe( + decodedQrPayload, + ); + }, 10_000); + + it("shows enterprise-required feedback inline without a destructive toast", async () => { + postMock.mockRejectedValueOnce( + axiosErrorWithDetail({ + error_code: "authenticator_feature_restricted", + message: "Enterprise plan required.", + vendor: "okta", + }), + ); + + await fillAndSubmitAuthenticatorCredential( + '{"methods":[{"type":"totp","sharedSecret":"JBSWY3DPEHPK3PXP"}]}', + ); + + await waitFor(() => { + expect(screen.getByText(/Skyvern enterprise plan/)).toBeTruthy(); + }); + expect(toastMock).not.toHaveBeenCalled(); + }, 10_000); +}); + +describe("CredentialsModal edit-mode inline test", () => { + it("updates the real credential and deletes the temp one instead of renaming the temp credential in place, even if 'save browser session' gets unchecked before saving", async () => { + // 1st POST: startTest's /credentials/test-login. 2nd POST: the real + // credential's /credentials/{id}/update once Save is clicked. + postMock + .mockResolvedValueOnce({ + data: { credential_id: "temp-cred-id", workflow_run_id: "wr-1" }, + }) + .mockResolvedValueOnce({ + data: { credential_id: "real-cred-id", name: "Acme Login" }, + }); + getMock.mockResolvedValueOnce({ + data: { + status: "completed", + browser_profile_id: "new-profile-id", + tested_url: "https://example.com/login", + }, + }); + patchMock.mockResolvedValue({ data: {} }); + deleteMock.mockResolvedValue({ data: {} }); + + renderEditPasswordCredentialsModal(); + + fireEvent.click(screen.getAllByLabelText("Edit credential values")[0]!); + const passwordInput = document.querySelector('input[type="password"]'); + expect(passwordInput).toBeTruthy(); + fireEvent.change(passwordInput as HTMLInputElement, { + target: { value: "rotated-password" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Test" })); + + // Real 3s poll delay inside the component — wait for the button label + // to flip once testStatus reaches "completed". + await waitFor( + () => { + expect(screen.getByRole("button", { name: "Retest" })).toBeTruthy(); + }, + { timeout: 8000 }, + ); + + // Uncheck "Save browser session" after the test completed — the checkbox + // has no side effect on testStatus/testCredentialId, so this must not be + // able to skip cleanup of the now-orphaned temp credential. + fireEvent.click( + screen.getByLabelText("Save browser session for future logins"), + ); + + fireEvent.click(screen.getByRole("button", { name: "Update" })); + + await waitFor(() => { + expect(deleteMock).toHaveBeenCalledWith("/credentials/temp-cred-id"); + }); + await waitFor(() => { + expect(postMock).toHaveBeenCalledWith( + "/credentials/real-cred-id/update", + expect.objectContaining({ + credential: expect.objectContaining({ + password: "rotated-password", + }), + }), + ); + }); + + // The bug this guards against: the old code renamed the throwaway temp + // credential in place of updating the real one. renameCredentialMutation + // is the only path that PATCHes a credential by the *temp* id — assert + // it never fired. + for (const call of patchMock.mock.calls) { + expect(call[0]).not.toBe("/credentials/temp-cred-id"); + } + }, 15_000); +}); diff --git a/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx b/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx index 7dc7c59be..3fdbd941c 100644 --- a/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx +++ b/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx @@ -14,16 +14,23 @@ import { PasswordCredentialContent } from "./PasswordCredentialContent"; import { SecretCredentialContent } from "./SecretCredentialContent"; import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; -import { CreditCardCredentialContent } from "./CreditCardCredentialContent"; +import { + CreditCardCredentialContent, + type CreditCardCredentialValues, +} from "./CreditCardCredentialContent"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - CreateCredentialRequest, - CredentialApiResponse, + type CreateCredentialRequest, + type CreditCardBillingAddress, + type CreditCardCredential, + type CredentialApiResponse, + PINNED_RESIDENTIAL_ISP_PROXY_LOCATION, isPasswordCredential, isCreditCardCredential, isSecretCredential, - TestCredentialStatusResponse, - TestLoginResponse, + type TestCredentialStatusResponse, + type TestLoginResponse, + type ProxyLocation, } from "@/api/types"; import { getClient } from "@/api/AxiosClient"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; @@ -44,6 +51,12 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { getHostname } from "@/util/getHostname"; import { useCustomCredentialServiceConfig } from "@/hooks/useCustomCredentialServiceConfig"; +import { getAuthenticatorKeyError } from "./credentialTotpValidation"; +import { + getAuthenticatorSaveError, + getCredentialErrorMessage, + type AuthenticatorSaveError, +} from "./authenticatorSaveError"; const PASSWORD_CREDENTIAL_INITIAL_VALUES = { name: "", @@ -54,14 +67,27 @@ const PASSWORD_CREDENTIAL_INITIAL_VALUES = { totp_identifier: "", }; -const CREDIT_CARD_CREDENTIAL_INITIAL_VALUES = { - name: "", - cardNumber: "", - cardExpirationDate: "", - cardCode: "", - cardBrand: "", - cardHolderName: "", -}; +function createCreditCardCredentialInitialValues(): CreditCardCredentialValues { + return { + name: "", + cardNumber: "", + cardExpirationDate: "", + cardCode: "", + cardBrand: "", + cardHolderName: "", + billingAddressLine1: "", + billingAddressLine2: "", + billingCity: "", + billingState: "", + billingStateCode: "", + billingPostalCode: "", + billingCountry: "", + billingCountryCode: "", + billingEmail: "", + billingPhone: "", + metadata: [{ key: "", value: "" }], + }; +} const SECRET_CREDENTIAL_INITIAL_VALUES = { name: "", @@ -101,6 +127,60 @@ function generateDefaultCredentialName(existingNames: string[]): string { return `${baseName}_${counter}`; } +function trimmedOrNull(value: string) { + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} + +function buildBillingAddress( + values: CreditCardCredentialValues, +): CreditCardBillingAddress | null { + const billingAddress: CreditCardBillingAddress = {}; + + const line1 = trimmedOrNull(values.billingAddressLine1); + const line2 = trimmedOrNull(values.billingAddressLine2); + const city = trimmedOrNull(values.billingCity); + const state = trimmedOrNull(values.billingState); + const stateCode = trimmedOrNull(values.billingStateCode); + const postalCode = trimmedOrNull(values.billingPostalCode); + const country = trimmedOrNull(values.billingCountry); + const countryCode = trimmedOrNull(values.billingCountryCode); + + if (line1) billingAddress.line1 = line1; + if (line2) billingAddress.line2 = line2; + if (city) billingAddress.city = city; + if (state) billingAddress.state = state; + if (stateCode) billingAddress.state_code = stateCode; + if (postalCode) billingAddress.postal_code = postalCode; + if (country) billingAddress.country = country; + if (countryCode) billingAddress.country_code = countryCode; + + return Object.keys(billingAddress).length > 0 ? billingAddress : null; +} + +function buildMetadata(values: CreditCardCredentialValues) { + const metadata: Record = {}; + let hasIncompleteEntry = false; + + for (const entry of values.metadata) { + const key = entry.key.trim(); + const value = entry.value.trim(); + if (!key && !value) { + continue; + } + if (!key || !value) { + hasIncompleteEntry = true; + continue; + } + metadata[key] = value; + } + + return { + metadata: Object.keys(metadata).length > 0 ? metadata : null, + hasIncompleteEntry, + }; +} + type Props = { onCredentialCreated?: (id: string) => void; /** Optional controlled mode: pass isOpen and onOpenChange to control modal state locally */ @@ -118,6 +198,19 @@ type Props = { ) => void; }; +type ProxyPinPayload = { + proxy_location: ProxyLocation | null; + proxy_session_id?: string | null; + rotate_proxy_session_id?: boolean; +}; + +function formatProxyIdentity(value?: string | null) { + if (!value) { + return null; + } + return `${value.slice(0, 3)}...${value.slice(-2)}`; +} + function CredentialsModal({ onCredentialCreated, isOpen: controlledIsOpen, @@ -151,7 +244,7 @@ function CredentialsModal({ PASSWORD_CREDENTIAL_INITIAL_VALUES, ); const [creditCardCredentialValues, setCreditCardCredentialValues] = useState( - CREDIT_CARD_CREDENTIAL_INITIAL_VALUES, + createCreditCardCredentialInitialValues, ); const [secretCredentialValues, setSecretCredentialValues] = useState( SECRET_CREDENTIAL_INITIAL_VALUES, @@ -160,6 +253,45 @@ function CredentialsModal({ name: false, values: false, }); + // Inline authenticator setup error returned by the backend when it rejects a + // decoded QR value or pasted key. Cleared whenever the user edits the key. + const [authenticatorSaveError, setAuthenticatorSaveError] = + useState(null); + + const handlePasswordCredentialChange = useCallback( + (next: typeof PASSWORD_CREDENTIAL_INITIAL_VALUES) => { + setPasswordCredentialValues((prev) => { + if (next.totp !== prev.totp || next.totp_type !== prev.totp_type) { + setAuthenticatorSaveError(null); + } + return next; + }); + }, + [], + ); + + const reportCredentialSaveError = useCallback( + (error: unknown, title = "Error"): string => { + const authError = getAuthenticatorSaveError(error); + if (authError && passwordCredentialValues.totp_type === "authenticator") { + setAuthenticatorSaveError(authError); + } + const description = + authError?.message ?? + getCredentialErrorMessage(error) ?? + (error instanceof Error + ? error.message + : "An unexpected error occurred"); + const isEnterpriseUpgrade = + authError?.code === "enterprise_required" && + passwordCredentialValues.totp_type === "authenticator"; + if (!isEnterpriseUpgrade) { + toast({ title, description, variant: "destructive" }); + } + return description; + }, + [passwordCredentialValues.totp_type], + ); const handleEnableEditName = useCallback(() => { setEditingGroups((prev) => ({ ...prev, name: true })); @@ -173,6 +305,11 @@ function CredentialsModal({ const [testAndSave, setTestAndSave] = useState(false); const [testUrl, setTestUrl] = useState(""); const [userContext, setUserContext] = useState(""); + const [pinResidentialIspProxy, setPinResidentialIspProxy] = useState(false); + const [rotateProxyPin, setRotateProxyPin] = useState(false); + const existingProxyIdentity = formatProxyIdentity( + editingCredential?.proxy_session_id, + ); const [testStatus, setTestStatus] = useState< "idle" | "testing" | "completed" | "failed" | "profile_failed" >("idle"); @@ -196,6 +333,30 @@ function CredentialsModal({ // Guards against in-flight poll responses updating state after cancel/close const pollCancelledRef = useRef(false); + const getProxyPinPayload = useCallback((): ProxyPinPayload => { + if (!pinResidentialIspProxy) { + return { + proxy_location: null, + proxy_session_id: null, + }; + } + return { + proxy_location: PINNED_RESIDENTIAL_ISP_PROXY_LOCATION, + ...(rotateProxyPin ? { rotate_proxy_session_id: true } : {}), + }; + }, [pinResidentialIspProxy, rotateProxyPin]); + + const hasProxyPinChanges = useCallback(() => { + if (!editingCredential) { + return pinResidentialIspProxy; + } + const existingPinEnabled = Boolean(editingCredential.proxy_session_id); + return ( + pinResidentialIspProxy !== existingPinEnabled || + (pinResidentialIspProxy && rotateProxyPin) + ); + }, [editingCredential, pinResidentialIspProxy, rotateProxyPin]); + // Captures save intent before mutation fires — testAndSave/testUrl may be // reset by the time onSuccess runs, so we snapshot them here. const saveIntentRef = useRef<{ @@ -204,12 +365,18 @@ function CredentialsModal({ testUrl: string; userContext: string; name: string; + proxyLocation: ProxyLocation | null; + proxySessionId?: string | null; + proxyPinChanged: boolean; }>({ shouldTestAfterSave: false, saveBrowserSessionIntent: false, testUrl: "", userContext: "", name: "", + proxyLocation: null, + proxySessionId: null, + proxyPinChanged: false, }); // Cleanup polling on unmount @@ -246,6 +413,8 @@ function CredentialsModal({ passwordCredentialValues.totp_type, passwordCredentialValues.totp_identifier, testUrl, + pinResidentialIspProxy, + rotateProxyPin, ]); const formInitializedRef = useRef(false); @@ -279,6 +448,8 @@ function CredentialsModal({ if (editingCredential.user_context) { setUserContext(editingCredential.user_context); } + setPinResidentialIspProxy(Boolean(editingCredential.proxy_session_id)); + setRotateProxyPin(false); if (isPasswordCredential(cred)) { setPasswordCredentialValues({ name: editingCredential.name, @@ -290,6 +461,7 @@ function CredentialsModal({ }); } else if (isCreditCardCredential(cred)) { setCreditCardCredentialValues({ + ...createCreditCardCredentialInitialValues(), name: editingCredential.name, cardNumber: "", cardExpirationDate: "", @@ -330,9 +502,10 @@ function CredentialsModal({ function reset() { setVaultType("default"); setPasswordCredentialValues(PASSWORD_CREDENTIAL_INITIAL_VALUES); - setCreditCardCredentialValues(CREDIT_CARD_CREDENTIAL_INITIAL_VALUES); + setCreditCardCredentialValues(createCreditCardCredentialInitialValues()); setSecretCredentialValues(SECRET_CREDENTIAL_INITIAL_VALUES); setEditingGroups({ name: false, values: false }); + setAuthenticatorSaveError(null); setTestAndSave(false); setTestUrl(""); setTestStatus("idle"); @@ -345,12 +518,17 @@ function CredentialsModal({ pollErrorCountRef.current = 0; pollCancelledRef.current = false; setUserContext(""); + setPinResidentialIspProxy(false); + setRotateProxyPin(false); saveIntentRef.current = { shouldTestAfterSave: false, saveBrowserSessionIntent: false, testUrl: "", userContext: "", name: "", + proxyLocation: null, + proxySessionId: null, + proxyPinChanged: false, }; if (pollIntervalRef.current) { clearTimeout(pollIntervalRef.current); @@ -485,8 +663,10 @@ function CredentialsModal({ ); const startTest = useCallback(async () => { + setAuthenticatorSaveError(null); try { const client = await getClient(credentialGetter, "sans-api-v1"); + const proxyPinPayload = getProxyPinPayload(); const response = await client.post( `/credentials/test-login`, { @@ -498,6 +678,7 @@ function CredentialsModal({ totp_identifier: passwordCredentialValues.totp_identifier.trim() || null, user_context: userContext.trim() || null, + ...proxyPinPayload, }, ); const data = response.data; @@ -529,15 +710,11 @@ function CredentialsModal({ }, 3000); } catch (error) { setTestStatus("failed"); - const detail = ( - (error as AxiosError)?.response?.data as { detail?: string } - )?.detail; - setTestFailureReason(detail ?? "Failed to start credential test"); - toast({ - title: "Failed to start credential test", - description: detail ?? "An unexpected error occurred", - variant: "destructive", - }); + const description = reportCredentialSaveError( + error, + "Failed to start credential test", + ); + setTestFailureReason(description); } }, [ credentialGetter, @@ -545,6 +722,8 @@ function CredentialsModal({ passwordCredentialValues, userContext, pollTestStatus, + getProxyPinPayload, + reportCredentialSaveError, ]); const createCredentialMutation = useMutation({ @@ -611,12 +790,7 @@ function CredentialsModal({ } }, onError: (error: AxiosError) => { - const detail = (error.response?.data as { detail?: string })?.detail; - toast({ - title: "Error", - description: detail ? detail : error.message, - variant: "destructive", - }); + reportCredentialSaveError(error); }, }); @@ -693,12 +867,7 @@ function CredentialsModal({ } }, onError: (error: AxiosError) => { - const detail = (error.response?.data as { detail?: string })?.detail; - toast({ - title: "Error", - description: detail ? detail : error.message, - variant: "destructive", - }); + reportCredentialSaveError(error); }, }); @@ -709,15 +878,23 @@ function CredentialsModal({ tested_url, user_context, save_browser_session_intent, + proxy_location, + proxy_session_id, + rotate_proxy_session_id, }: { id: string; name: string; tested_url?: string; user_context?: string | null; save_browser_session_intent?: boolean; + proxy_location?: ProxyLocation | null; + proxy_session_id?: string | null; + rotate_proxy_session_id?: boolean; }) => { const client = await getClient(credentialGetter, "sans-api-v1"); - const body: Record = { name }; + const body: Record = { + name, + }; if (tested_url) { body.tested_url = tested_url; } @@ -727,6 +904,15 @@ function CredentialsModal({ if (save_browser_session_intent !== undefined) { body.save_browser_session_intent = save_browser_session_intent; } + if (proxy_location !== undefined) { + body.proxy_location = proxy_location; + } + if (proxy_session_id !== undefined) { + body.proxy_session_id = proxy_session_id; + } + if (rotate_proxy_session_id !== undefined) { + body.rotate_proxy_session_id = rotate_proxy_session_id; + } const response = await client.patch( `/credentials/${id}`, body, @@ -747,12 +933,7 @@ function CredentialsModal({ }); }, onError: (error: AxiosError) => { - const detail = (error.response?.data as { detail?: string })?.detail; - toast({ - title: "Error", - description: detail ? detail : error.message, - variant: "destructive", - }); + reportCredentialSaveError(error); }, }); @@ -760,20 +941,6 @@ function CredentialsModal({ ? updateCredentialMutation : createCredentialMutation; - const handleRenameOnly = (name: string) => { - if (!editingCredential) return; - // Skip the API call if the name hasn't actually changed - if (name === editingCredential.name) { - reset(); - setIsOpen(false); - return; - } - renameCredentialMutation.mutate({ - id: editingCredential.credential_id, - name, - }); - }; - const handleSave = () => { const name = type === CredentialModalTypes.PASSWORD @@ -790,16 +957,25 @@ function CredentialsModal({ return; } + const proxyPinPayload = getProxyPinPayload(); + const proxyPinChanged = hasProxyPinChanges(); + const credentialSaveProxyPinPayload = + !isEditMode || proxyPinChanged ? proxyPinPayload : {}; + // In edit mode, use editingGroups to determine what changed (type-agnostic) if (isEditMode && editingCredential) { - if (!editingGroups.name && !editingGroups.values) { + if (!editingGroups.name && !editingGroups.values && !proxyPinChanged) { // Nothing was edited — close silently reset(); setIsOpen(false); return; } - if (editingGroups.name && !editingGroups.values) { - handleRenameOnly(name); + if (!editingGroups.values) { + renameCredentialMutation.mutate({ + id: editingCredential.credential_id, + name, + ...(proxyPinChanged ? proxyPinPayload : {}), + }); return; } } @@ -818,9 +994,18 @@ function CredentialsModal({ }); return; } + if (authenticatorKeyError) { + return; + } + setAuthenticatorSaveError(null); - // If test passed, rename the temp credential instead of creating a new one - if (testAndSave && testStatus === "completed" && testCredentialId) { + const hasCompletedInlineTest = + testAndSave && testStatus === "completed" && !!testCredentialId; + + // Create mode: the temp credential created by the inline test already holds + // the entered secret and the saved browser profile, so rename it in place + // instead of creating a duplicate. + if (!isEditMode && hasCompletedInlineTest && testCredentialId) { const url = testUrl.trim(); const ctx = userContext.trim(); renameCredentialMutation.mutate({ @@ -829,10 +1014,27 @@ function CredentialsModal({ tested_url: url || undefined, user_context: ctx || null, save_browser_session_intent: true, + ...proxyPinPayload, }); return; } + // Any remaining temp credential is an orphan we're not reusing — either + // we're in edit mode (the real credential gets updated below instead), + // or "Save browser session" got unchecked after a completed test. Key + // this on testCredentialId alone, not hasCompletedInlineTest: the + // checkbox is togglable post-test with no side effect on testStatus, so + // gating on testAndSave here silently leaked the temp credential (and + // the secret it holds) whenever the box was unchecked before saving. + if (testCredentialId) { + const tempCredentialId = testCredentialId; + getClient(credentialGetter) + .then((client) => client.delete(`/credentials/${tempCredentialId}`)) + .catch(() => { + // Best-effort cleanup + }); + } + // Capture intent before mutation — state will be reset in onSuccess // In edit mode, only trigger a background test if the user actually changed // credentials, user_context, or URL — not just because the checkbox was pre-checked. @@ -844,13 +1046,17 @@ function CredentialsModal({ saveIntentRef.current = { shouldTestAfterSave: testAndSave && - testStatus !== "completed" && + (testStatus !== "completed" || + (isEditMode && hasCompletedInlineTest)) && testUrl.trim() !== "" && hasEditModeChanges, saveBrowserSessionIntent: testAndSave, testUrl: testUrl.trim(), userContext: userContext.trim(), name, + proxyLocation: proxyPinPayload.proxy_location, + proxySessionId: proxyPinPayload.proxy_session_id, + proxyPinChanged, }; activeMutation.mutate({ @@ -864,6 +1070,7 @@ function CredentialsModal({ totp_identifier: totpIdentifier === "" ? null : totpIdentifier, }, ...(vaultType === "custom" ? { vault_type: "custom" } : {}), + ...credentialSaveProxyPinPayload, }); } else if (type === CredentialModalTypes.CREDIT_CARD) { const cardNumber = creditCardCredentialValues.cardNumber.trim(); @@ -909,18 +1116,52 @@ function CredentialsModal({ } // remove all spaces from the card number const number = creditCardCredentialValues.cardNumber.replace(/\s/g, ""); + const billingAddress = buildBillingAddress(creditCardCredentialValues); + const billingEmail = trimmedOrNull( + creditCardCredentialValues.billingEmail, + ); + const billingPhone = trimmedOrNull( + creditCardCredentialValues.billingPhone, + ); + const { metadata, hasIncompleteEntry } = buildMetadata( + creditCardCredentialValues, + ); + if (hasIncompleteEntry) { + toast({ + title: "Error", + description: "Metadata rows need both a key and a value", + variant: "destructive", + }); + return; + } + const credentialPayload: CreditCardCredential = { + card_number: number, + card_cvv: cardCode, + card_exp_month: cardExpirationMonth, + card_exp_year: cardExpirationYear, + card_brand: cardBrand, + card_holder_name: cardHolderName, + ...(billingAddress ? { billing_address: billingAddress } : {}), + ...(billingEmail ? { billing_email: billingEmail } : {}), + ...(billingPhone ? { billing_phone: billingPhone } : {}), + ...(metadata ? { metadata } : {}), + }; + saveIntentRef.current = { + shouldTestAfterSave: false, + saveBrowserSessionIntent: false, + testUrl: "", + userContext: "", + name, + proxyLocation: proxyPinPayload.proxy_location, + proxySessionId: proxyPinPayload.proxy_session_id, + proxyPinChanged, + }; activeMutation.mutate({ name, credential_type: "credit_card", - credential: { - card_number: number, - card_cvv: cardCode, - card_exp_month: cardExpirationMonth, - card_exp_year: cardExpirationYear, - card_brand: cardBrand, - card_holder_name: cardHolderName, - }, + credential: credentialPayload, ...(vaultType === "custom" ? { vault_type: "custom" } : {}), + ...credentialSaveProxyPinPayload, }); } else if (type === CredentialModalTypes.SECRET) { const secretValue = secretCredentialValues.secretValue.trim(); @@ -935,6 +1176,16 @@ function CredentialsModal({ return; } + saveIntentRef.current = { + shouldTestAfterSave: false, + saveBrowserSessionIntent: false, + testUrl: "", + userContext: "", + name, + proxyLocation: proxyPinPayload.proxy_location, + proxySessionId: proxyPinPayload.proxy_session_id, + proxyPinChanged, + }; activeMutation.mutate({ name, credential_type: "secret", @@ -943,6 +1194,7 @@ function CredentialsModal({ secret_label: secretLabel === "" ? null : secretLabel, }, ...(vaultType === "custom" ? { vault_type: "custom" } : {}), + ...credentialSaveProxyPinPayload, }); } }; @@ -990,12 +1242,79 @@ function CredentialsModal({ ) : undefined; + const shouldValidateAuthenticatorKey = + type === CredentialModalTypes.PASSWORD && + (!isEditMode || editingGroups.values); + const authenticatorKeyError = shouldValidateAuthenticatorKey + ? getAuthenticatorKeyError(passwordCredentialValues) + : null; + + const proxyPinContent = ( +
+
+ + setPinResidentialIspProxy(checked === true) + } + disabled={isTestInProgress} + className="mt-0.5" + /> +
+
+ + +
+

+ Helps avoid extra 2FA, captchas, or temporary locks caused by + signing in from a new location. +

+ {pinResidentialIspProxy && existingProxyIdentity && ( +
+

+ Consistent IP active: identity {existingProxyIdentity} +

+
+ + {rotateProxyPin && ( + + A new IP identity will be created when you save. + + )} +
+
+ )} + {pinResidentialIspProxy && !existingProxyIdentity && ( +

+ Skyvern will create an IP identity for this credential when you + save. +

+ )} +
+
+
+ ); + const credentialContent = (() => { if (type === CredentialModalTypes.PASSWORD) { return ( @@ -1166,6 +1487,9 @@ function CredentialsModal({ }); return; } + if (authenticatorKeyError) { + return; + } // Set testing state immediately to avoid button flash pollCancelledRef.current = false; @@ -1220,6 +1544,7 @@ function CredentialsModal({ testUrl.trim() !== "" && passwordCredentialValues.username.trim() !== "" && passwordCredentialValues.password.trim() !== "" && + !authenticatorKeyError && !isTestInProgress; return ( @@ -1274,6 +1599,7 @@ function CredentialsModal({ )} {credentialContent} + {proxyPinContent}
diff --git a/skyvern-frontend/src/routes/credentials/CredentialsPage.tsx b/skyvern-frontend/src/routes/credentials/CredentialsPage.tsx index 8cc621798..2b2fc6d20 100644 --- a/skyvern-frontend/src/routes/credentials/CredentialsPage.tsx +++ b/skyvern-frontend/src/routes/credentials/CredentialsPage.tsx @@ -9,6 +9,8 @@ import { } from "./useCredentialModalState"; import { CredentialsModal } from "./CredentialsModal"; import { CredentialsList } from "./CredentialsList"; +import { BitwardenCredentialsList } from "./BitwardenCredentialsList"; +import { OnePasswordCredentialsList } from "./OnePasswordCredentialsList"; import { useBackgroundCredentialTest } from "./useBackgroundCredentialTest"; import { DropdownMenu, @@ -317,6 +319,14 @@ function CredentialsPage() { isResolvingFolder={isResolvingFolder} onStartBackgroundTest={startBackgroundTest} /> + + diff --git a/skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx b/skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx index 1d65a6ff9..399be6b84 100644 --- a/skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx +++ b/skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx @@ -1,5 +1,7 @@ import { useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { ArrowRightIcon } from "@radix-ui/react-icons"; import { PushTotpCodeForm } from "@/components/PushTotpCodeForm"; import { useTotpCodesQuery } from "@/hooks/useTotpCodesQuery"; import { Input } from "@/components/ui/input"; @@ -22,18 +24,65 @@ import { TableRow, } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; -import type { OtpType, TotpCode } from "@/api/types"; +import { + OtpType, + type OtpType as OtpTypeValue, + type TotpCode, +} from "@/api/types"; import { Skeleton } from "@/components/ui/skeleton"; import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat"; +import { GmailIcon } from "@/components/icons/GmailIcon"; -type OtpTypeFilter = "all" | OtpType; +type OtpTypeFilter = "all" | OtpTypeValue; const LIMIT_OPTIONS = [25, 50, 100] as const; -function renderCodeContent(code: TotpCode): string { +function safeHttpUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" + ? url.toString() + : null; + } catch { + return null; + } +} + +function otpTypeLabel(otpType: OtpTypeValue | null): string { + switch (otpType) { + case OtpType.Totp: + return "Numeric code"; + case OtpType.MagicLink: + return "Magic link"; + default: + return "unknown"; + } +} + +function renderCodeContent(code: TotpCode) { if (!code.code) { return "—"; } + if (code.otp_type === OtpType.MagicLink) { + const href = safeHttpUrl(code.code); + if (!href) { + return ( + + {code.code} + + ); + } + return ( + + Open magic link + + ); + } return code.code; } @@ -69,6 +118,30 @@ function CredentialsTotpTab() { return (
+
+
+
+
+ +
+
+

+ Connect Gmail for automatic 2FA +

+

+ Skyvern can find verification codes and magic links in a + connected Gmail inbox without manual forwarding. +

+
+
+ +
+
+

Push a 2FA Code

@@ -119,8 +192,8 @@ function CredentialsTotpTab() { All types - Numeric code - Magic link + Numeric code + Magic link

@@ -177,7 +250,7 @@ function CredentialsTotpTab() { Identifier - Code + Verification Source Agent Run Created @@ -221,7 +294,7 @@ function CredentialsTotpTab() { - {code.otp_type ?? "unknown"} + {otpTypeLabel(code.otp_type)} {code.source ? ( diff --git a/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.test.tsx b/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.test.tsx new file mode 100644 index 000000000..c41161258 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/DropdownWithOptions", () => ({ + DropdownWithOptions: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => ( + onChange(event.target.value)} + /> + ), +})); + +import { + CreditCardCredentialContent, + type CreditCardCredentialValues, +} from "./CreditCardCredentialContent"; + +const INITIAL_VALUES: CreditCardCredentialValues = { + name: "card", + cardNumber: "", + cardExpirationDate: "", + cardCode: "", + cardBrand: "", + cardHolderName: "", + billingAddressLine1: "", + billingAddressLine2: "", + billingCity: "", + billingState: "", + billingStateCode: "", + billingPostalCode: "", + billingCountry: "", + billingCountryCode: "", + billingEmail: "", + billingPhone: "", + metadata: [{ key: "", value: "" }], +}; + +function Harness({ + onChangeSpy, +}: { + onChangeSpy: (next: CreditCardCredentialValues) => void; +}) { + const [values, setValues] = + useState(INITIAL_VALUES); + + return ( + { + onChangeSpy(next); + setValues(next); + }} + /> + ); +} + +describe("CreditCardCredentialContent", () => { + afterEach(() => cleanup()); + + it("collects optional metadata key-value rows", () => { + const onChangeSpy = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Metadata key 1"), { + target: { value: "customer_id" }, + }); + fireEvent.change(screen.getByLabelText("Metadata value 1"), { + target: { value: "cus_123" }, + }); + fireEvent.click(screen.getByText("Add")); + fireEvent.change(screen.getByLabelText("Metadata key 2"), { + target: { value: "checkout_profile" }, + }); + fireEvent.change(screen.getByLabelText("Metadata value 2"), { + target: { value: "default" }, + }); + + const lastCall = onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1]; + expect(lastCall?.[0].metadata).toEqual([ + { key: "customer_id", value: "cus_123" }, + { key: "checkout_profile", value: "default" }, + ]); + }); +}); diff --git a/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.tsx b/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.tsx index 86289c91e..28adb2e13 100644 --- a/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.tsx +++ b/skyvern-frontend/src/routes/credentials/CreditCardCredentialContent.tsx @@ -1,27 +1,51 @@ import { DropdownWithOptions } from "@/components/DropdownWithOptions"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/util/utils"; -import { Pencil1Icon } from "@radix-ui/react-icons"; +import { Pencil1Icon, PlusIcon, TrashIcon } from "@radix-ui/react-icons"; + +export type CreditCardMetadataEntry = { + key: string; + value: string; +}; + +export type CreditCardCredentialValues = { + name: string; + cardNumber: string; + cardExpirationDate: string; + cardCode: string; + cardBrand: string; + cardHolderName: string; + billingAddressLine1: string; + billingAddressLine2: string; + billingCity: string; + billingState: string; + billingStateCode: string; + billingPostalCode: string; + billingCountry: string; + billingCountryCode: string; + billingEmail: string; + billingPhone: string; + metadata: CreditCardMetadataEntry[]; +}; + +type BillingFieldKey = + | "billingAddressLine1" + | "billingAddressLine2" + | "billingCity" + | "billingState" + | "billingStateCode" + | "billingPostalCode" + | "billingCountry" + | "billingCountryCode" + | "billingEmail" + | "billingPhone"; type Props = { - values: { - name: string; - cardNumber: string; - cardExpirationDate: string; - cardCode: string; - cardBrand: string; - cardHolderName: string; - }; - onChange: (values: { - name: string; - cardNumber: string; - cardExpirationDate: string; - cardCode: string; - cardBrand: string; - cardHolderName: string; - }) => void; + values: CreditCardCredentialValues; + onChange: (values: CreditCardCredentialValues) => void; /** Slot rendered right after Name, before the separator */ beforeCredentialFields?: React.ReactNode; editMode?: boolean; @@ -43,6 +67,54 @@ const brandOptions = [ "Other", ]; +const billingFields: Array<{ + label: string; + key: BillingFieldKey; + autoComplete?: string; + className?: string; +}> = [ + { + label: "Address Line 1", + key: "billingAddressLine1", + autoComplete: "billing address-line1", + className: "md:col-span-3", + }, + { + label: "Address Line 2", + key: "billingAddressLine2", + autoComplete: "billing address-line2", + className: "md:col-span-3", + }, + { label: "City", key: "billingCity", autoComplete: "billing address-level2" }, + { + label: "State", + key: "billingState", + autoComplete: "billing address-level1", + }, + { label: "State Code", key: "billingStateCode" }, + { + label: "Postal Code", + key: "billingPostalCode", + autoComplete: "billing postal-code", + }, + { + label: "Country", + key: "billingCountry", + autoComplete: "billing country-name", + }, + { + label: "Country Code", + key: "billingCountryCode", + autoComplete: "billing country", + }, + { + label: "Billing Email", + key: "billingEmail", + autoComplete: "billing email", + }, + { label: "Billing Phone", key: "billingPhone", autoComplete: "billing tel" }, +]; + function formatCardNumber(cardNumber: string) { // put spaces every 4 digits return cardNumber.replace(/(\d{4})(?=\d)/g, "$1 "); @@ -53,6 +125,27 @@ function formatCardExpirationDate(cardExpirationDate: string) { return cardExpirationDate.replace(/(\d{2})(?=\d)/g, "$1/"); } +function updateMetadataEntry( + entries: CreditCardMetadataEntry[], + index: number, + field: keyof CreditCardMetadataEntry, + value: string, +) { + return entries.map((entry, entryIndex) => + entryIndex === index ? { ...entry, [field]: value } : entry, + ); +} + +function removeMetadataEntry( + entries: CreditCardMetadataEntry[], + index: number, +) { + if (entries.length <= 1) { + return [{ key: "", value: "" }]; + } + return entries.filter((_, entryIndex) => entryIndex !== index); +} + function CreditCardCredentialContent({ values, onChange, @@ -64,6 +157,7 @@ function CreditCardCredentialContent({ }: Props) { const nameReadOnly = editMode && !editingGroups?.name; const valuesReadOnly = editMode && !editingGroups?.values; + const optionalFieldClassName = cn({ "opacity-70": valuesReadOnly }); return (
@@ -221,6 +315,106 @@ function CreditCardCredentialContent({ )}
+ +
+
Billing Details
+
+ {billingFields.map((field) => ( +
+ + + onChange({ ...values, [field.key]: e.target.value }) + } + readOnly={valuesReadOnly} + className={optionalFieldClassName} + autoComplete={field.autoComplete} + /> +
+ ))} +
+
+
+
+
Metadata
+ +
+
+ {values.metadata.map((entry, index) => ( +
+ + onChange({ + ...values, + metadata: updateMetadataEntry( + values.metadata, + index, + "key", + e.target.value, + ), + }) + } + readOnly={valuesReadOnly} + className={optionalFieldClassName} + placeholder="Key" + aria-label={`Metadata key ${index + 1}`} + /> + + onChange({ + ...values, + metadata: updateMetadataEntry( + values.metadata, + index, + "value", + e.target.value, + ), + }) + } + readOnly={valuesReadOnly} + className={optionalFieldClassName} + placeholder="Value" + aria-label={`Metadata value ${index + 1}`} + /> + +
+ ))} +
+
); } diff --git a/skyvern-frontend/src/routes/credentials/OnePasswordCredentialsList.tsx b/skyvern-frontend/src/routes/credentials/OnePasswordCredentialsList.tsx new file mode 100644 index 000000000..f9a79f275 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/OnePasswordCredentialsList.tsx @@ -0,0 +1,121 @@ +import type { OnePasswordItemApiResponse } from "@/api/types"; +import { OnePasswordIcon } from "@/components/icons/OnePasswordIcon"; +import { getHostname } from "@/util/getHostname"; +import { useOnePasswordItemsQuery } from "@/routes/workflows/hooks/useOnePasswordItemsQuery"; +import { useMemo } from "react"; + +type Props = { + search?: string; + folderId?: string | null; +}; + +function isPasswordItem(item: OnePasswordItemApiResponse) { + const category = item.category.toLowerCase(); + return category.includes("login") || category.includes("password"); +} + +function OnePasswordCredentialsList({ search, folderId }: Props) { + const { data, isLoading, isError } = useOnePasswordItemsQuery(); + const normalizedSearch = search?.trim().toLowerCase() ?? ""; + + const filteredItems = useMemo(() => { + return (data?.items ?? []).filter((item) => { + if (!isPasswordItem(item)) { + return false; + } + + if (!normalizedSearch) { + return true; + } + + return item.title.toLowerCase().includes(normalizedSearch); + }); + }, [data?.items, normalizedSearch]); + + const header = ( +
+ +
+

1Password

+

+ Read-only — managed in your 1Password account +

+
+
+ ); + + if (folderId || isLoading) { + return null; + } + + if (isError) { + return ( +
+ {header} +
+ Couldn't load 1Password items. +
+
+ ); + } + + if (!data?.configured || filteredItems.length === 0) { + return null; + } + + return ( +
+ {header} +
+ {filteredItems.map((item) => ( +
+
+ +
+

+ {item.title} +

+

+ {item.vault_name} +

+
+
+
+
+ {item.url && ( +

+ Website +

+ )} +

+ Vault ID +

+

+ Item ID +

+
+
+ {item.url && ( +

+ {getHostname(item.url) ?? item.url} +

+ )} +

+ {item.vault_id} +

+

+ {item.item_id} +

+
+
+
+ ))} +
+
+ ); +} + +export { OnePasswordCredentialsList }; diff --git a/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.test.tsx b/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.test.tsx index 57263498a..2b33fd126 100644 --- a/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.test.tsx +++ b/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.test.tsx @@ -6,11 +6,13 @@ import { fireEvent, render, screen, + waitFor, } from "@testing-library/react"; import { useEffect, useState } from "react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { CredentialAuthenticatorSupportProvider } from "./CredentialAuthenticatorSupportContext"; import { PasswordCredentialContent } from "./PasswordCredentialContent"; type Values = { @@ -40,6 +42,36 @@ const SAVED_VALUES: Values = { totp_identifier: "saved-totp-id@example.com", }; +const ENTERPRISE_APPS = { + label: "Enterprise QR support", + apps: ["Enterprise Authenticator A", "Enterprise Authenticator B"], + contactUrl: "https://www.skyvern.com/contact", + qrCodeTypes: [ + { + id: "example", + label: "Enterprise Authenticator A", + }, + { + id: "opaque", + label: "Enterprise Authenticator B", + }, + ], + inferQrCodeType: (value: string) => { + if (value.startsWith("example-authenticator://")) { + return "example"; + } + if (value.startsWith("opaque-authenticator://")) { + return "opaque"; + } + return null; + }, + vendorLabels: { + opaque: "Enterprise Authenticator B", + }, + description: + "Scan the QR as usual - Skyvern detects these setup codes automatically.", +}; + // Mirrors CredentialsModal: starts the form at INITIAL_VALUES, then after mount // applies the loaded credential via setPasswordCredentialValues — same flow // that produced SKY-9864. @@ -72,7 +104,10 @@ function ModalLikeHarness({ } describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression)", () => { - afterEach(() => cleanup()); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); it("preserves saved totp_identifier when modal hydrates an email-TOTP credential into a form that started at PASSWORD_CREDENTIAL_INITIAL_VALUES", async () => { // Reproduces the bug: @@ -149,6 +184,274 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression expect(autoFillCall).toBeTruthy(); }); + it("marks Authenticator App as the selected 2FA method when a new credential opens the 2FA section", async () => { + const onChangeSpy = vi.fn(); + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByText("Two-Factor Authentication")); + }); + + const authenticatorCall = onChangeSpy.mock.calls.find((call) => { + const next = call[0] as Values; + return next.totp_type === "authenticator"; + }); + expect(authenticatorCall).toBeTruthy(); + }); + + it("keeps Authenticator App selected when the user types a key without clicking the method tile", async () => { + const onChangeSpy = vi.fn(); + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByText("Two-Factor Authentication")); + }); + fireEvent.change(screen.getByPlaceholderText("e.g. JBSWY3DPEHPK3PXP"), { + target: { value: "JBSWY3DPEHPK3PXP" }, + }); + + const typedCall = onChangeSpy.mock.calls.find((call) => { + const next = call[0] as Values; + return ( + next.totp_type === "authenticator" && next.totp === "JBSWY3DPEHPK3PXP" + ); + }); + expect(typedCall).toBeTruthy(); + }); + + it("imports an otpauth URI from an uploaded QR code image", async () => { + const onChangeSpy = vi.fn(); + const imageBitmap = { close: vi.fn() } as unknown as ImageBitmap; + class MockBarcodeDetector { + detect = vi.fn().mockResolvedValue([ + { + rawValue: + "otpauth://totp/user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + }, + ]); + } + vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue(imageBitmap)); + vi.stubGlobal("BarcodeDetector", MockBarcodeDetector); + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByText("Two-Factor Authentication")); + }); + fireEvent.change(screen.getByLabelText("Upload QR code image"), { + target: { + files: [new File(["qr"], "totp.png", { type: "image/png" })], + }, + }); + + await waitFor(() => { + const qrCall = onChangeSpy.mock.calls.find((call) => { + const next = call[0] as Values; + return ( + next.totp_type === "authenticator" && + next.totp.startsWith("otpauth://totp/") + ); + }); + expect(qrCall).toBeTruthy(); + }); + expect( + screen + .getByRole("button", { name: /Google Authenticator/ }) + .getAttribute("aria-pressed"), + ).toBe("true"); + expect( + screen.getByTestId("authenticator-qr-type-detection").textContent, + ).toBe("Detected from QR"); + expect(imageBitmap.close).toHaveBeenCalled(); + }); + + it("imports a non-otpauth QR payload for server-side validation", async () => { + const onChangeSpy = vi.fn(); + const imageBitmap = { close: vi.fn() } as unknown as ImageBitmap; + class MockBarcodeDetector { + detect = vi.fn().mockResolvedValue([ + { + rawValue: "custom-authenticator://activate?payload=opaque", + }, + ]); + } + vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue(imageBitmap)); + vi.stubGlobal("BarcodeDetector", MockBarcodeDetector); + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByText("Two-Factor Authentication")); + }); + fireEvent.change(screen.getByLabelText("Upload QR code image"), { + target: { + files: [new File(["qr"], "totp.png", { type: "image/png" })], + }, + }); + + await waitFor(() => { + const qrCall = onChangeSpy.mock.calls.find((call) => { + const next = call[0] as Values; + return ( + next.totp_type === "authenticator" && + next.totp === "custom-authenticator://activate?payload=opaque" + ); + }); + expect(qrCall).toBeTruthy(); + }); + expect( + screen + .getByRole("button", { name: /Google Authenticator/ }) + .getAttribute("aria-pressed"), + ).toBe("false"); + expect(screen.queryByText("Detected from QR")).toBeNull(); + expect(imageBitmap.close).toHaveBeenCalled(); + }); + + it("shows an inline fallback when the browser cannot scan QR codes", async () => { + const onChangeSpy = vi.fn(); + vi.stubGlobal("BarcodeDetector", undefined); + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByText("Two-Factor Authentication")); + }); + fireEvent.change(screen.getByLabelText("Upload QR code image"), { + target: { + files: [new File(["qr"], "totp.png", { type: "image/png" })], + }, + }); + + await waitFor(() => { + expect( + screen.getByText( + "QR scanning is not supported by this browser. Paste the setup key or otpauth:// URI instead.", + ), + ).toBeTruthy(); + }); + }); + + it("shows the QR fallback when the browser rejects QR detection", async () => { + const onChangeSpy = vi.fn(); + const createImageBitmapMock = vi.fn(); + class MockBarcodeDetector { + constructor() { + throw new TypeError("Unsupported barcode format"); + } + + detect = vi.fn(); + } + vi.stubGlobal("createImageBitmap", createImageBitmapMock); + vi.stubGlobal("BarcodeDetector", MockBarcodeDetector); + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByText("Two-Factor Authentication")); + }); + fireEvent.change(screen.getByLabelText("Upload QR code image"), { + target: { + files: [new File(["qr"], "totp.png", { type: "image/png" })], + }, + }); + + await waitFor(() => { + expect( + screen.getByText( + "QR scanning is not supported by this browser. Paste the setup key or otpauth:// URI instead.", + ), + ).toBeTruthy(); + }); + expect(createImageBitmapMock).not.toHaveBeenCalled(); + }); + it("preserves an intentionally-empty totp_identifier when hydrating a saved email-TOTP credential", async () => { // Regression for the empty-string false-positive in the username-rename // follow-on path: at mount, prevUsername="" and identifier="". After @@ -250,4 +553,374 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression }); expect(reseedCall).toBeTruthy(); }); + + it("allows QR upload to set the TOTP payload", async () => { + const onChangeSpy = vi.fn(); + const close = vi.fn(); + const detect = vi + .fn() + .mockResolvedValue([{ rawValue: "decoded-qr-payload" }]); + class BarcodeDetector { + detect: typeof detect; + + constructor() { + this.detect = detect; + } + } + vi.stubGlobal("BarcodeDetector", BarcodeDetector); + vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue({ close })); + + const { container } = render( + + + , + ); + const input = container.querySelector('input[type="file"]'); + expect(input).toBeTruthy(); + const file = new File(["qr"], "qr.png", { type: "image/png" }); + + await act(async () => { + fireEvent.change(input as HTMLInputElement, { + target: { files: [file] }, + }); + }); + + await waitFor(() => + expect(onChangeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + totp: "decoded-qr-payload", + totp_type: "authenticator", + }), + ), + ); + expect(close).toHaveBeenCalled(); + }); +}); + +describe("PasswordCredentialContent — supported authenticator copy", () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + const NEW_VALUES: Values = { + name: "New cred", + username: "new-user@example.com", + password: "password", + totp: "", + totp_type: "none", + totp_identifier: "", + }; + + function stubQrCodeScan(rawValue: string) { + const imageBitmap = { close: vi.fn() } as unknown as ImageBitmap; + class MockBarcodeDetector { + detect = vi.fn().mockResolvedValue([{ rawValue }]); + } + vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue(imageBitmap)); + vi.stubGlobal("BarcodeDetector", MockBarcodeDetector); + return imageBitmap; + } + + async function uploadQrCodeImage() { + await act(async () => { + fireEvent.change(screen.getByLabelText("Upload QR code image"), { + target: { + files: [new File(["qr"], "totp.png", { type: "image/png" })], + }, + }); + }); + } + + async function openAuthenticatorSection( + enterpriseApps?: typeof ENTERPRISE_APPS, + onChangeSpy = vi.fn(), + ) { + const content = ( + + + + ); + render( + enterpriseApps ? ( + + {content} + + ) : ( + content + ), + ); + await act(async () => { + fireEvent.click(screen.getByText("Two-Factor Authentication")); + }); + return onChangeSpy; + } + + it("always lists generic TOTP apps", async () => { + await openAuthenticatorSection(); + expect( + screen.getByText(/Google Authenticator, Authy, 1Password/), + ).toBeTruthy(); + }); + + it("omits enterprise QR type options in the default/OSS context", async () => { + await openAuthenticatorSection(); + expect( + screen.getByRole("button", { name: /Google Authenticator/ }), + ).toBeTruthy(); + expect(screen.queryByText("Enterprise Authenticator A")).toBeNull(); + expect(screen.queryByText("Enterprise Authenticator B")).toBeNull(); + expect( + screen.queryByText(/detects these setup codes automatically/), + ).toBeNull(); + }); + + it("renders Cloud provider QR type options when the context provides apps", async () => { + await openAuthenticatorSection(ENTERPRISE_APPS); + const selector = screen.getByTestId("authenticator-qr-type-selector"); + expect(selector).toBeTruthy(); + const keyInput = screen.getByPlaceholderText("e.g. JBSWY3DPEHPK3PXP"); + expect( + selector.compareDocumentPosition(keyInput) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: /Google Authenticator/ }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Enterprise Authenticator A" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Enterprise Authenticator B" }), + ).toBeTruthy(); + expect( + screen.getByText(/detects these setup codes automatically/), + ).toBeTruthy(); + expect( + selector.querySelectorAll('[data-testid="authenticator-type-logo"]'), + ).toHaveLength(3); + }); + + it("highlights the Cloud provider QR type inferred from an uploaded QR code", async () => { + const imageBitmap = stubQrCodeScan( + "example-authenticator://activate?payload=opaque", + ); + const onChangeSpy = await openAuthenticatorSection(ENTERPRISE_APPS); + await uploadQrCodeImage(); + + await waitFor(() => { + const qrCall = onChangeSpy.mock.calls.find((call) => { + const next = call[0] as Values; + return ( + next.totp_type === "authenticator" && + next.totp === "example-authenticator://activate?payload=opaque" + ); + }); + expect(qrCall).toBeTruthy(); + }); + expect( + screen + .getByRole("button", { name: /Enterprise Authenticator A/ }) + .getAttribute("aria-pressed"), + ).toBe("true"); + expect( + screen.getByTestId("authenticator-qr-type-detection").textContent, + ).toBe("Detected from QR"); + expect(imageBitmap.close).toHaveBeenCalled(); + }); + + it("announces the inferred type via a polite live region naming the detected app", async () => { + stubQrCodeScan("example-authenticator://activate?payload=opaque"); + await openAuthenticatorSection(ENTERPRISE_APPS); + await uploadQrCodeImage(); + + const liveRegion = await waitFor(() => { + const region = screen + .getByTestId("authenticator-qr-type-detection") + .closest('[role="status"]'); + expect(region).toBeTruthy(); + return region as HTMLElement; + }); + expect(liveRegion.getAttribute("aria-live")).toBe("polite"); + expect(liveRegion.textContent).toContain("Detected from QR"); + expect(liveRegion.textContent).toContain("Enterprise Authenticator A"); + expect( + screen.getByTestId("authenticator-qr-type-detection").textContent, + ).toBe("Detected from QR"); + }); + + it("visually marks only the inferred chip as detected, and clears that marker when the user picks another type", async () => { + stubQrCodeScan("example-authenticator://activate?payload=opaque"); + await openAuthenticatorSection(ENTERPRISE_APPS); + await uploadQrCodeImage(); + + const inferredButton = await waitFor(() => { + const button = screen.getByRole("button", { + name: /Enterprise Authenticator A/, + }); + expect(button.getAttribute("data-inferred")).toBe("true"); + return button; + }); + expect( + screen + .getByRole("button", { name: /Google Authenticator/ }) + .getAttribute("data-inferred"), + ).toBeNull(); + + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: /Google Authenticator/ }), + ); + }); + + expect(inferredButton.getAttribute("data-inferred")).toBeNull(); + expect(screen.queryByText("Detected from QR")).toBeNull(); + expect( + screen + .getByRole("button", { name: /Google Authenticator/ }) + .getAttribute("aria-pressed"), + ).toBe("true"); + }); + + it("does not falsely highlight a Cloud enterprise type for an unknown uploaded QR code", async () => { + const imageBitmap = stubQrCodeScan( + "unknown-authenticator://activate?payload=opaque", + ); + const onChangeSpy = await openAuthenticatorSection(ENTERPRISE_APPS); + await uploadQrCodeImage(); + + await waitFor(() => { + const qrCall = onChangeSpy.mock.calls.find((call) => { + const next = call[0] as Values; + return ( + next.totp_type === "authenticator" && + next.totp === "unknown-authenticator://activate?payload=opaque" + ); + }); + expect(qrCall).toBeTruthy(); + }); + expect( + screen + .getByRole("button", { name: /Google Authenticator/ }) + .getAttribute("aria-pressed"), + ).toBe("false"); + expect( + screen + .getByRole("button", { name: "Enterprise Authenticator A" }) + .getAttribute("aria-pressed"), + ).toBe("false"); + expect( + screen + .getByRole("button", { name: "Enterprise Authenticator B" }) + .getAttribute("aria-pressed"), + ).toBe("false"); + expect(screen.queryByText("Detected from QR")).toBeNull(); + expect(imageBitmap.close).toHaveBeenCalled(); + }); +}); + +describe("PasswordCredentialContent — inline authenticator save error", () => { + afterEach(() => { + cleanup(); + }); + + const AUTH_VALUES: Values = { + name: "New cred", + username: "new-user@example.com", + password: "password", + totp: "otpauth://totp/user@example.com?secret=BAD", + totp_type: "authenticator", + totp_identifier: "", + }; + + it("shows enterprise copy with a contact link for an enterprise-required error", () => { + render( + + + + + , + ); + + const enterpriseMessage = screen.getByText( + "Enterprise Authenticator B requires a Skyvern enterprise plan.", + ); + expect(enterpriseMessage.closest(".text-destructive")).toBeNull(); + expect(screen.getByTestId("enterprise-authenticator-upgrade")).toBeTruthy(); + const contactLink = screen.getByRole("link", { name: "Contact us" }); + expect(contactLink.getAttribute("href")).toBe( + "https://www.skyvern.com/contact", + ); + const input = screen.getByPlaceholderText("e.g. JBSWY3DPEHPK3PXP"); + expect(input.getAttribute("aria-invalid")).toBe("false"); + expect(input.className).not.toContain("border-destructive"); + expect(input.getAttribute("aria-describedby")).toBeTruthy(); + }); + + it("shows actionable no-code-secret copy without a contact link", () => { + render( + + + , + ); + + expect(screen.getByText(/push-approval app/)).toBeTruthy(); + expect(screen.queryByRole("link", { name: "Contact us" })).toBeNull(); + const input = screen.getByPlaceholderText("e.g. JBSWY3DPEHPK3PXP"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(input.className).toContain("border-destructive"); + const describedBy = input.getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy!)?.textContent).toContain( + "push-approval app", + ); + }); + + it("keeps the decoded QR value in the field after a save failure", () => { + render( + + + , + ); + + const input = screen.getByPlaceholderText( + "e.g. JBSWY3DPEHPK3PXP", + ) as HTMLInputElement; + expect(input.value).toBe("otpauth://totp/user@example.com?secret=BAD"); + }); }); diff --git a/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.tsx b/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.tsx index 8a6de2789..c6f6bc852 100644 --- a/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.tsx +++ b/skyvern-frontend/src/routes/credentials/PasswordCredentialContent.tsx @@ -1,3 +1,4 @@ +import googleAuthenticatorIcon from "@/assets/authenticators/google-authenticator.jpg"; import { QRCodeIcon } from "@/components/icons/QRCodeIcon"; import { Accordion, @@ -5,19 +6,38 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/util/utils"; import { + CheckIcon, EnvelopeClosedIcon, EyeNoneIcon, EyeOpenIcon, + MagicWandIcon, MobileIcon, Pencil1Icon, + ReloadIcon, + UploadIcon, } from "@radix-ui/react-icons"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; import { Link } from "react-router-dom"; +import { + type CredentialAuthenticatorQrCodeType, + useCredentialAuthenticatorSupport, +} from "./CredentialAuthenticatorSupportContext"; +import { AuthenticatorAppLogo } from "./AuthenticatorAppLogo"; +import { decodeQrCodeImage } from "./decodeQrCodeImage"; +import { type AuthenticatorSaveError } from "./authenticatorSaveError"; type Props = { values: { @@ -51,8 +71,40 @@ type Props = { editingGroups?: { name: boolean; values: boolean }; onEnableEditName?: () => void; onEnableEditValues?: () => void; + totpError?: string | null; + /** Server-side authenticator setup error surfaced inline near the key field */ + authenticatorSaveError?: AuthenticatorSaveError | null; }; +const STANDARD_AUTHENTICATOR_TYPE_ID = "standard"; + +const STANDARD_AUTHENTICATOR_TYPE: CredentialAuthenticatorQrCodeType = { + id: STANDARD_AUTHENTICATOR_TYPE_ID, + label: "Google Authenticator", + description: "Standard TOTP setup code", + logo: , +}; + +type AuthenticatorTypeDetectionSource = "qr" | "typed" | "saved" | "backend"; + +function compactAuthenticatorSecret(value: string): string { + return value.replace(/[\s-]/g, ""); +} + +function isStandardAuthenticatorValue(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + + if (/^otpauth:\/\/totp\//i.test(trimmed)) { + return true; + } + + const compact = compactAuthenticatorSecret(trimmed); + return compact.length >= 16 && /^[A-Z2-7]+=*$/i.test(compact); +} + function PasswordCredentialContent({ values, onChange, @@ -66,8 +118,30 @@ function PasswordCredentialContent({ editingGroups, onEnableEditName, onEnableEditValues, + totpError, + authenticatorSaveError, }: Props) { + const { enterpriseApps } = useCredentialAuthenticatorSupport(); const { name, username, password, totp, totp_type, totp_identifier } = values; + + const enterpriseUpgradeError = + authenticatorSaveError?.code === "enterprise_required" + ? authenticatorSaveError + : null; + const enterpriseUpgradeVendorLabel = enterpriseUpgradeError?.vendor + ? enterpriseApps?.vendorLabels?.[ + enterpriseUpgradeError.vendor.toLowerCase() + ] + : null; + const enterpriseUpgradeMessage = enterpriseUpgradeVendorLabel + ? `${enterpriseUpgradeVendorLabel} requires a Skyvern enterprise plan.` + : enterpriseUpgradeError?.message; + const enterpriseContactUrl = enterpriseApps?.contactUrl; + const destructiveSaveError = + authenticatorSaveError && + authenticatorSaveError.code !== "enterprise_required" + ? authenticatorSaveError + : null; const nameReadOnly = editMode && !editingGroups?.name; const valuesReadOnly = editMode && !editingGroups?.values; @@ -78,6 +152,103 @@ function PasswordCredentialContent({ ); const [totpAccordionValue, setTotpAccordionValue] = useState(""); const [showPassword, setShowPassword] = useState(false); + const [qrCodeScanError, setQrCodeScanError] = useState(null); + const [isScanningQrCode, setIsScanningQrCode] = useState(false); + const [selectedAuthenticatorTypeId, setSelectedAuthenticatorTypeId] = + useState(STANDARD_AUTHENTICATOR_TYPE_ID); + const [inferredAuthenticatorTypeId, setInferredAuthenticatorTypeId] = + useState(null); + const [ + authenticatorTypeDetectionSource, + setAuthenticatorTypeDetectionSource, + ] = useState(null); + const inferredAuthenticatorValueRef = useRef(""); + const qrCodeInputRef = useRef(null); + const authenticatorKeyErrorId = useId(); + const enterpriseUpgradeErrorId = useId(); + + const authenticatorTypeOptions = useMemo( + () => [STANDARD_AUTHENTICATOR_TYPE, ...(enterpriseApps?.qrCodeTypes ?? [])], + [enterpriseApps?.qrCodeTypes], + ); + const authenticatorTypeOptionIds = useMemo( + () => new Set(authenticatorTypeOptions.map(({ id }) => id)), + [authenticatorTypeOptions], + ); + const detectionStatusLabel = + authenticatorTypeDetectionSource === "qr" + ? "Detected from QR" + : authenticatorTypeDetectionSource === "typed" + ? "Detected from value" + : authenticatorTypeDetectionSource === "saved" + ? "Detected from saved key" + : authenticatorTypeDetectionSource === "backend" + ? "Detected on save" + : null; + const inferredAuthenticatorTypeLabel = useMemo( + () => + authenticatorTypeOptions.find( + ({ id }) => id === inferredAuthenticatorTypeId, + )?.label ?? null, + [authenticatorTypeOptions, inferredAuthenticatorTypeId], + ); + const destructiveAuthenticatorMessages = ( + [totpError, qrCodeScanError, destructiveSaveError?.message] as const + ).filter((message): message is string => Boolean(message)); + const hasAuthenticatorKeyError = Boolean( + destructiveAuthenticatorMessages.length, + ); + const authenticatorKeyDescriptionIds = [ + hasAuthenticatorKeyError ? authenticatorKeyErrorId : null, + enterpriseUpgradeError ? enterpriseUpgradeErrorId : null, + ] + .filter(Boolean) + .join(" "); + + const inferAuthenticatorTypeId = useCallback( + (value: string): string | null => { + if (isStandardAuthenticatorValue(value)) { + return STANDARD_AUTHENTICATOR_TYPE_ID; + } + + const inferredEnterpriseType = enterpriseApps?.inferQrCodeType?.(value); + if ( + inferredEnterpriseType && + authenticatorTypeOptionIds.has(inferredEnterpriseType) + ) { + return inferredEnterpriseType; + } + + return null; + }, + [authenticatorTypeOptionIds, enterpriseApps], + ); + + const applyAuthenticatorTypeInference = useCallback( + ( + value: string, + source: AuthenticatorTypeDetectionSource, + { + clearUnknownSelection = false, + }: { clearUnknownSelection?: boolean } = {}, + ) => { + const inferredTypeId = inferAuthenticatorTypeId(value); + if (inferredTypeId) { + inferredAuthenticatorValueRef.current = value; + setSelectedAuthenticatorTypeId(inferredTypeId); + setInferredAuthenticatorTypeId(inferredTypeId); + setAuthenticatorTypeDetectionSource(source); + return; + } + + setInferredAuthenticatorTypeId(null); + setAuthenticatorTypeDetectionSource(null); + if (clearUnknownSelection) { + setSelectedAuthenticatorTypeId(null); + } + }, + [inferAuthenticatorTypeId], + ); // Sync totpMethod and auto-expand accordion when totp_type prop changes // (e.g. edit data arriving after mount) @@ -91,6 +262,53 @@ function PasswordCredentialContent({ setTotpAccordionValue("two-factor-authentication"); } }, [totp_type]); + + useEffect(() => { + if ( + selectedAuthenticatorTypeId && + !authenticatorTypeOptionIds.has(selectedAuthenticatorTypeId) + ) { + setSelectedAuthenticatorTypeId(STANDARD_AUTHENTICATOR_TYPE_ID); + } + if ( + inferredAuthenticatorTypeId && + !authenticatorTypeOptionIds.has(inferredAuthenticatorTypeId) + ) { + setInferredAuthenticatorTypeId(null); + setAuthenticatorTypeDetectionSource(null); + } + }, [ + authenticatorTypeOptionIds, + inferredAuthenticatorTypeId, + selectedAuthenticatorTypeId, + ]); + + useEffect(() => { + if (totpMethod !== "authenticator" || !totp) { + return; + } + if (totp === inferredAuthenticatorValueRef.current) { + return; + } + + const inferredTypeId = inferAuthenticatorTypeId(totp); + if (inferredTypeId) { + inferredAuthenticatorValueRef.current = totp; + setSelectedAuthenticatorTypeId(inferredTypeId); + setInferredAuthenticatorTypeId(inferredTypeId); + setAuthenticatorTypeDetectionSource("saved"); + } + }, [inferAuthenticatorTypeId, totp, totpMethod]); + + useEffect(() => { + const vendor = enterpriseUpgradeError?.vendor?.toLowerCase(); + if (vendor && authenticatorTypeOptionIds.has(vendor)) { + setSelectedAuthenticatorTypeId(vendor); + setInferredAuthenticatorTypeId(vendor); + setAuthenticatorTypeDetectionSource("backend"); + } + }, [authenticatorTypeOptionIds, enterpriseUpgradeError?.vendor]); + const prevUsernameRef = useRef(username); const totpIdentifierLabel = totpMethod === "text" @@ -147,6 +365,7 @@ function PasswordCredentialContent({ onEnableEditValues?.(); const prevMethod = totpMethod; setTotpMethod(method); + setQrCodeScanError(null); const updates: Partial = { totp: method === "authenticator" ? totp : "", @@ -170,6 +389,150 @@ function PasswordCredentialContent({ updateValues(updates); }; + const handleTotpAccordionValueChange = (value: string) => { + setTotpAccordionValue(value); + if ( + value === "two-factor-authentication" && + totp_type === "none" && + !valuesReadOnly + ) { + handleTotpMethodChange(totpMethod); + } + }; + + const handleAuthenticatorTotpChange = (value: string) => { + onEnableEditValues?.(); + setQrCodeScanError(null); + setTotpMethod("authenticator"); + if (!value) { + inferredAuthenticatorValueRef.current = ""; + setSelectedAuthenticatorTypeId(STANDARD_AUTHENTICATOR_TYPE_ID); + setInferredAuthenticatorTypeId(null); + setAuthenticatorTypeDetectionSource(null); + } else { + applyAuthenticatorTypeInference(value, "typed"); + } + updateValues({ totp: value, totp_type: "authenticator" }); + }; + + const handleQrCodeFileChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + + onEnableEditValues?.(); + setIsScanningQrCode(true); + setQrCodeScanError(null); + try { + const qrCodeValue = await decodeQrCodeImage(file); + setTotpMethod("authenticator"); + applyAuthenticatorTypeInference(qrCodeValue, "qr", { + clearUnknownSelection: true, + }); + updateValues({ totp: qrCodeValue, totp_type: "authenticator" }); + } catch (caught) { + setQrCodeScanError( + caught instanceof Error + ? caught.message + : "Unable to scan that QR code. Paste the setup key instead.", + ); + } finally { + setIsScanningQrCode(false); + } + }; + + const authenticatorTypeSelector = ( +
+
+ +
+ {detectionStatusLabel && inferredAuthenticatorTypeId && ( + <> + + {detectionStatusLabel} + + {inferredAuthenticatorTypeLabel && ( + + {` — ${inferredAuthenticatorTypeLabel}`} + + )} + + )} +
+
+
+ {authenticatorTypeOptions.map((option) => { + const selected = selectedAuthenticatorTypeId === option.id; + const inferred = inferredAuthenticatorTypeId === option.id; + return ( + + ); + })} +
+ {enterpriseApps?.description && ( +

+ {enterpriseApps.description} +

+ )} +
+ ); + return (
@@ -287,7 +650,7 @@ function PasswordCredentialContent({ type="single" collapsible value={totpAccordionValue} - onValueChange={setTotpAccordionValue} + onValueChange={handleTotpAccordionValueChange} > @@ -302,39 +665,61 @@ function PasswordCredentialContent({
handleTotpMethodChange("authenticator")} > + {totpMethod === "authenticator" && ( + + + + )} - +
handleTotpMethodChange("email")} > + {totpMethod === "email" && ( + + + + )} - +
handleTotpMethodChange("text")} > + {totpMethod === "text" && ( + + + + )} - +
{(totpMethod === "text" || totpMethod === "email") && ( @@ -370,24 +755,28 @@ function PasswordCredentialContent({

- - Contact us to set up two-factor authentication in - workflows - {" "} - or{" "} + {enterpriseContactUrl && ( + <> + + Contact us to set up two-factor authentication in + workflows + {" "} + or{" "} + + )} - see our documentation on how to set up two-factor - authentication in workflows + {enterpriseContactUrl ? "see" : "See"} our documentation + on how to set up two-factor authentication in workflows {" "} to get started.

@@ -395,10 +784,12 @@ function PasswordCredentialContent({ )} {totpMethod === "authenticator" && (
+ {authenticatorTypeSelector}
{valuesReadOnly ? ( @@ -418,17 +809,96 @@ function PasswordCredentialContent({
) : ( - updateValues({ totp: e.target.value })} - placeholder={editMode ? "••••••••" : undefined} - /> +
+ + handleAuthenticatorTotpChange(e.target.value) + } + placeholder="e.g. JBSWY3DPEHPK3PXP" + aria-invalid={hasAuthenticatorKeyError} + aria-describedby={ + authenticatorKeyDescriptionIds || undefined + } + className={cn( + hasAuthenticatorKeyError && + "border-destructive bg-destructive/10 focus-visible:ring-destructive/30", + )} + /> + + void handleQrCodeFileChange(event) + } + /> + +
)}
+ {destructiveAuthenticatorMessages.length > 0 && ( +
+ {destructiveAuthenticatorMessages.map( + (message, index) => ( +

{message}

+ ), + )} +
+ )} + {enterpriseUpgradeError && ( +
+ + {enterpriseUpgradeMessage} + + {enterpriseContactUrl && ( + <> + {" "} + + + Contact us + {" "} + to enable enterprise authenticator support. + + + )} +
+ )}

- You need to find the authenticator secret from the website + Works with Google Authenticator, Authy, 1Password, and any + standard TOTP authenticator app. +

+

+ You need to find the authenticator key from the website where you are using the credential. Here are some guides - from popular authenticator apps:{" "} + from popular password managers:{" "} { + it("maps the structured no-code-secret code to actionable code-based setup copy", () => { + const result = getAuthenticatorSaveError( + axiosErrorWithDetail({ + error_code: "authenticator_no_code_secret", + message: "Push enrollment payloads are not supported.", + }), + ); + + expect(result?.code).toBe("no_code_secret"); + expect(result?.message).toMatch(/code-based setup key/i); + expect(result?.message).toMatch(/push-approval/i); + expect(result?.message).toMatch(/authenticator app or one-time code/i); + }); + + it("maps the structured enterprise-required code to enterprise access copy", () => { + const result = getAuthenticatorSaveError( + axiosErrorWithDetail({ + error_code: "authenticator_feature_restricted", + message: "Enterprise plan required.", + }), + ); + + expect(result?.code).toBe("enterprise_required"); + expect(result?.message).toMatch(/enterprise plan/i); + }); + + it("preserves the detected vendor id for enterprise-required copy", () => { + const result = getAuthenticatorSaveError( + axiosErrorWithDetail({ + error_code: "authenticator_feature_restricted", + message: "Enterprise plan required.", + vendor: "example", + }), + ); + + expect(result?.code).toBe("enterprise_required"); + expect(result?.message).toBe( + "This authenticator requires a Skyvern enterprise plan.", + ); + expect(result?.vendor).toBe("example"); + }); + + it("maps unsupported TOTP config to the backend message", () => { + const result = getAuthenticatorSaveError( + axiosErrorWithDetail({ + error_code: "authenticator_totp_config_unsupported", + message: "The authenticator setup code is malformed.", + }), + ); + + expect(result?.code).toBe("unsupported_totp_config"); + expect(result?.message).toBe("The authenticator setup code is malformed."); + }); + + it("falls back to the backend message for an invalid-key code", () => { + const result = getAuthenticatorSaveError( + axiosErrorWithDetail({ + error_code: "invalid_authenticator_key", + message: "That is not a valid Base32 secret.", + }), + ); + + expect(result?.code).toBe("invalid_authenticator_key"); + expect(result?.message).toBe("That is not a valid Base32 secret."); + }); + + it("does not infer unsupported push enrollment from unknown future codes", () => { + const result = getAuthenticatorSaveError( + axiosErrorWithDetail({ + error_code: "batch_enrollment_failed", + message: "The authenticator setup could not be saved.", + }), + ); + + expect(result?.code).toBe("unknown"); + expect(result?.message).toBe("The authenticator setup could not be saved."); + }); + + it("classifies a legacy string detail that mentions the authenticator key", () => { + const result = getAuthenticatorSaveError( + axiosErrorWithDetail( + "Invalid authenticator key. Paste the raw Base32 setup key.", + ), + ); + + expect(result?.code).toBe("invalid_authenticator_key"); + expect(result?.message).toContain("Invalid authenticator key"); + }); + + it("returns null for a legacy string detail unrelated to the authenticator", () => { + expect( + getAuthenticatorSaveError( + axiosErrorWithDetail("Username and password are required"), + ), + ).toBeNull(); + }); + + it("returns null when there is no response detail", () => { + expect(getAuthenticatorSaveError(new Error("boom"))).toBeNull(); + }); +}); + +describe("getCredentialErrorMessage", () => { + it("returns a legacy string detail verbatim", () => { + expect( + getCredentialErrorMessage(axiosErrorWithDetail("Something went wrong")), + ).toBe("Something went wrong"); + }); + + it("returns the message field from a structured detail", () => { + expect( + getCredentialErrorMessage( + axiosErrorWithDetail({ + error_code: "enterprise_required", + message: "Enterprise plan required.", + }), + ), + ).toBe("Enterprise plan required."); + }); + + it("returns null when no usable detail is present", () => { + expect(getCredentialErrorMessage(new Error("boom"))).toBeNull(); + }); +}); diff --git a/skyvern-frontend/src/routes/credentials/authenticatorSaveError.ts b/skyvern-frontend/src/routes/credentials/authenticatorSaveError.ts new file mode 100644 index 000000000..36e76293e --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/authenticatorSaveError.ts @@ -0,0 +1,146 @@ +import { isAxiosError } from "axios"; + +type AuthenticatorErrorCode = + | "no_code_secret" + | "unsupported_totp_config" + | "enterprise_required" + | "invalid_authenticator_key" + | "unknown"; + +type AuthenticatorSaveError = { + code: AuthenticatorErrorCode; + message: string; + vendor?: string; +}; + +type StructuredErrorDetail = { + error_code?: unknown; + message?: unknown; + vendor?: unknown; +}; + +const EXACT_ERROR_CODE_MAP: Record = { + authenticator_no_code_secret: "no_code_secret", + authenticator_totp_config_unsupported: "unsupported_totp_config", + authenticator_feature_restricted: "enterprise_required", + invalid_authenticator_key: "invalid_authenticator_key", + authenticator_key_required: "invalid_authenticator_key", +}; + +const NO_CODE_SECRET_MESSAGE = + "This QR code doesn't contain a code-based setup key that Skyvern can use. It may enroll a push-approval app or device-bound authenticator. In the site's security settings, choose an authenticator app or one-time code, or use a TOTP setup key instead, then scan that QR code or paste its setup key."; + +const INVALID_KEY_MESSAGE = + "Invalid authenticator key. Paste the raw Base32 setup key or full otpauth:// URI from the website's 2FA setup screen."; +const UNSUPPORTED_TOTP_CONFIG_MESSAGE = + "We recognized this as an authenticator setup QR, but its code configuration is malformed or unsupported. Use a standard setup key or otpauth:// URI instead."; + +function enterpriseMessage(): string { + return "This authenticator requires a Skyvern enterprise plan."; +} + +function classifyCode(rawCode: string): AuthenticatorErrorCode | null { + const code = rawCode.toLowerCase(); + const exactCode = EXACT_ERROR_CODE_MAP[code]; + if (exactCode) { + return exactCode; + } + return rawCode ? "unknown" : null; +} + +function resolveMessage( + code: AuthenticatorErrorCode, + backendMessage: string | undefined, +): string { + switch (code) { + case "no_code_secret": + return NO_CODE_SECRET_MESSAGE; + case "unsupported_totp_config": + return backendMessage?.trim() || UNSUPPORTED_TOTP_CONFIG_MESSAGE; + case "enterprise_required": + return enterpriseMessage(); + case "invalid_authenticator_key": + return backendMessage?.trim() || INVALID_KEY_MESSAGE; + case "unknown": + return backendMessage?.trim() || INVALID_KEY_MESSAGE; + } +} + +function fromStructuredDetail( + detail: StructuredErrorDetail, +): AuthenticatorSaveError | null { + const rawCode = + typeof detail.error_code === "string" ? detail.error_code : ""; + const backendMessage = + typeof detail.message === "string" ? detail.message : undefined; + const vendor = typeof detail.vendor === "string" ? detail.vendor : undefined; + const code = classifyCode(rawCode); + if (!code) { + if (backendMessage && /authenticator/i.test(backendMessage)) { + return { code: "unknown", message: backendMessage.trim(), vendor }; + } + return null; + } + return { + code, + message: resolveMessage(code, backendMessage), + vendor, + }; +} + +function fromStringDetail(detail: string): AuthenticatorSaveError | null { + const trimmed = detail.trim(); + if (!/authenticator key/i.test(trimmed)) { + return null; + } + const code: AuthenticatorErrorCode = /invalid/i.test(trimmed) + ? "invalid_authenticator_key" + : "unknown"; + return { code, message: trimmed }; +} + +function getErrorDetail(error: unknown): unknown { + if (isAxiosError<{ detail?: unknown }>(error)) { + return error.response?.data?.detail; + } + return undefined; +} + +/** + * Extracts authenticator-specific user-facing error info from Axios failures, including legacy string details and structured `{ error_code, message, vendor? }` details. + * Returns null when the error is not an authenticator setup failure so callers can fall back to generic toast handling. + */ +function getAuthenticatorSaveError( + error: unknown, +): AuthenticatorSaveError | null { + const detail = getErrorDetail(error); + if (typeof detail === "string") { + return fromStringDetail(detail); + } + if (detail && typeof detail === "object") { + return fromStructuredDetail(detail as StructuredErrorDetail); + } + return null; +} + +/** + * Extracts a plain user-facing string from an Axios error for toast copy, + * normalizing both the legacy string `detail` and the structured object + * `detail` shapes. Returns null when no detail is present. + */ +function getCredentialErrorMessage(error: unknown): string | null { + const detail = getErrorDetail(error); + if (typeof detail === "string") { + return detail.trim() || null; + } + if (detail && typeof detail === "object") { + const message = (detail as StructuredErrorDetail).message; + if (typeof message === "string" && message.trim()) { + return message.trim(); + } + } + return null; +} + +export { getAuthenticatorSaveError, getCredentialErrorMessage }; +export type { AuthenticatorSaveError, AuthenticatorErrorCode }; diff --git a/skyvern-frontend/src/routes/credentials/credentialTotpValidation.ts b/skyvern-frontend/src/routes/credentials/credentialTotpValidation.ts new file mode 100644 index 000000000..af4fabe80 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/credentialTotpValidation.ts @@ -0,0 +1,22 @@ +const AUTHENTICATOR_KEY_REQUIRED_MESSAGE = "Authenticator key is required."; + +type AuthenticatorKeyValues = { + totp: string; + totp_type: "authenticator" | "email" | "text" | "none"; +}; + +function getAuthenticatorKeyError( + values: AuthenticatorKeyValues, +): string | null { + if (values.totp_type !== "authenticator") { + return null; + } + + const raw = values.totp.trim(); + if (raw === "") { + return AUTHENTICATOR_KEY_REQUIRED_MESSAGE; + } + return null; +} + +export { getAuthenticatorKeyError }; diff --git a/skyvern-frontend/src/routes/credentials/decodeQrCodeImage.ts b/skyvern-frontend/src/routes/credentials/decodeQrCodeImage.ts new file mode 100644 index 000000000..26fd5c317 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/decodeQrCodeImage.ts @@ -0,0 +1,48 @@ +type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => { + detect: (image: ImageBitmap) => Promise>; +}; + +type BrowserBarcodeDetectorScope = typeof globalThis & { + BarcodeDetector?: BarcodeDetectorConstructor; +}; + +const QR_SCAN_UNSUPPORTED_MESSAGE = + "QR scanning is not supported by this browser. Paste the setup key or otpauth:// URI instead."; +const QR_CODE_NOT_FOUND_MESSAGE = + "No QR code was found in that image. Try a clearer screenshot or paste the setup key."; + +async function decodeQrCodeImage(file: File): Promise { + const scope = globalThis as BrowserBarcodeDetectorScope; + const BarcodeDetector = scope.BarcodeDetector; + + if (!BarcodeDetector) { + throw new Error(QR_SCAN_UNSUPPORTED_MESSAGE); + } + + let detector: InstanceType; + try { + detector = new BarcodeDetector({ formats: ["qr_code"] }); + } catch { + throw new Error(QR_SCAN_UNSUPPORTED_MESSAGE); + } + + let image: ImageBitmap; + try { + image = await globalThis.createImageBitmap(file); + } catch { + throw new Error(QR_SCAN_UNSUPPORTED_MESSAGE); + } + + try { + const codes = await detector.detect(image); + const rawValue = codes.find((code) => code.rawValue)?.rawValue?.trim(); + if (!rawValue) { + throw new Error(QR_CODE_NOT_FOUND_MESSAGE); + } + return rawValue; + } finally { + image.close(); + } +} + +export { decodeQrCodeImage }; diff --git a/skyvern-frontend/src/routes/credentials/hooks/useCredentialFolderMutations.ts b/skyvern-frontend/src/routes/credentials/hooks/useCredentialFolderMutations.ts index 824a2d8e5..5251cb220 100644 --- a/skyvern-frontend/src/routes/credentials/hooks/useCredentialFolderMutations.ts +++ b/skyvern-frontend/src/routes/credentials/hooks/useCredentialFolderMutations.ts @@ -75,6 +75,11 @@ function useDeleteCredentialFolderMutation() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["credential-folders"] }); queryClient.invalidateQueries({ queryKey: ["credentials"] }); + toast({ + variant: "success", + title: "Folder deleted", + description: "The folder has been deleted.", + }); }, onError: (error: Error) => { toast({ diff --git a/skyvern-frontend/src/routes/discover/WorkflowTemplates.tsx b/skyvern-frontend/src/routes/discover/WorkflowTemplates.tsx index 8f5f5006f..4ea886ac7 100644 --- a/skyvern-frontend/src/routes/discover/WorkflowTemplates.tsx +++ b/skyvern-frontend/src/routes/discover/WorkflowTemplates.tsx @@ -1,4 +1,6 @@ import { Skeleton } from "@/components/ui/skeleton"; +import { useWorkflowStudioEnabled } from "@/hooks/useWorkflowStudioEnabled"; +import { workflowEditorPath } from "@/routes/workflows/studioNavigation"; import { useGlobalWorkflowsQuery } from "../workflows/hooks/useGlobalWorkflowsQuery"; import { useNavigate } from "react-router-dom"; import { WorkflowTemplateCard } from "./WorkflowTemplateCard"; @@ -15,6 +17,7 @@ import { function WorkflowTemplates() { const { data: workflowTemplates, isLoading } = useGlobalWorkflowsQuery(); const navigate = useNavigate(); + const studioEnabled = useWorkflowStudioEnabled(); if (isLoading) { return ( @@ -54,7 +57,10 @@ function WorkflowTemplates() { } onClick={() => { navigate( - `/workflows/${workflow.workflow_permanent_id}/build`, + workflowEditorPath( + workflow.workflow_permanent_id, + studioEnabled, + ), ); }} /> diff --git a/skyvern-frontend/src/routes/history/RunHistory.test.tsx b/skyvern-frontend/src/routes/history/RunHistory.test.tsx new file mode 100644 index 000000000..4b4a372ac --- /dev/null +++ b/skyvern-frontend/src/routes/history/RunHistory.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; + +import { TaskRunType, TriggerType, type TaskRunListItem } from "@/api/types"; +import { RunHistory } from "./RunHistory"; + +const workflowRun: TaskRunListItem = { + task_run_id: "tr_1", + task_run_type: TaskRunType.WorkflowRun, + run_id: "wr_123", + title: "My Run", + status: "completed", + started_at: "2026-06-14T10:00:00Z", + finished_at: "2026-06-14T10:01:00Z", + created_at: "2026-06-14T10:00:00Z", + workflow_permanent_id: "wpid_1", + workflow_deleted: false, + script_run: false, + trigger_type: null, + searchable_text: "city Paris", +}; + +// Stable references so an active search doesn't churn the runs identity +// across renders (which would retrigger effects under test). +const runsData = [workflowRun]; +const runsQueryResult = { data: runsData, isFetching: false }; + +vi.mock("use-debounce", () => ({ + useDebounce: (value: T): [T] => [value], +})); + +vi.mock("posthog-js/react", () => ({ + useFeatureFlagVariantKey: () => undefined, + useFeatureFlagEnabled: () => false, +})); + +vi.mock("@/hooks/useRunsQuery", () => ({ + useRunsQuery: () => runsQueryResult, +})); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => vi.fn(), +})); + +vi.mock("@/routes/workflows/hooks/useGlobalWorkflowsQuery", () => ({ + useGlobalWorkflowsQuery: () => ({ data: [] }), +})); + +vi.mock("@/components/StatusFilterDropdown", () => ({ + StatusFilterDropdown: () =>

, +})); + +vi.mock("@/components/TriggerTypeBadge", () => ({ + TriggerTypeBadge: ({ triggerType }: { triggerType: string }) => ( + {triggerType} + ), +})); + +vi.mock("@/components/onboarding/OnboardingEmptyState", () => ({ + OnboardingEmptyState: () =>
, +})); + +vi.mock("@/store/onboarding/useOnboardingState", () => ({ + useOnboardingStateOptional: () => null, +})); + +vi.mock("@/components/TableSearchInput", () => ({ + TableSearchInput: ({ + value, + onChange, + placeholder, + disabled, + }: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + }) => ( + onChange(event.target.value)} + /> + ), +})); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: vi.fn(async () => ({ + get: vi.fn(async () => ({ + data: { parameters: { city: "Paris" }, extra_http_headers: null }, + })), + })), +})); + +function renderRunHistory() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +function activateSearch() { + fireEvent.change(screen.getByLabelText("search-runs"), { + target: { value: "city" }, + }); +} + +afterEach(() => { + runsData.splice(0, runsData.length, workflowRun); + cleanup(); + vi.clearAllMocks(); +}); + +describe("RunHistory inputs during filtering", () => { + it("keeps Run Inputs collapsed while a search is active", () => { + renderRunHistory(); + activateSearch(); + + // The run is in the results, but its inputs pane must not auto-expand: + // only the header row and the single data row should be present. + expect(screen.getByText("wr_123")).toBeTruthy(); + expect(screen.getAllByRole("row")).toHaveLength(2); + expect(screen.queryByText("Run Inputs")).toBeNull(); + }); + + it("still lets the user expand Run Inputs during a search", async () => { + renderRunHistory(); + activateSearch(); + + const runRow = screen.getByText("wr_123").closest("tr"); + expect(runRow).not.toBeNull(); + fireEvent.click(within(runRow as HTMLElement).getByRole("button")); + + expect(await screen.findByText("Run Inputs")).toBeTruthy(); + expect(screen.getByText("Paris")).toBeTruthy(); + }); + + it("renders the MCP trigger badge from persisted trigger_type", () => { + runsData.splice(0, runsData.length, { + ...workflowRun, + trigger_type: TriggerType.Mcp, + }); + + renderRunHistory(); + + expect(screen.getByTestId("trigger-type-mcp")).toBeTruthy(); + }); +}); diff --git a/skyvern-frontend/src/routes/history/RunHistory.tsx b/skyvern-frontend/src/routes/history/RunHistory.tsx index 3a8dfbb8c..029fe1c23 100644 --- a/skyvern-frontend/src/routes/history/RunHistory.tsx +++ b/skyvern-frontend/src/routes/history/RunHistory.tsx @@ -42,6 +42,7 @@ import { TableRow, } from "@/components/ui/table"; import { useRunsQuery } from "@/hooks/useRunsQuery"; +import { useWorkflowStudioEnabled } from "@/hooks/useWorkflowStudioEnabled"; import { basicLocalTimeFormat, basicTimeFormat, @@ -49,7 +50,7 @@ import { } from "@/util/timeFormat"; import { cn } from "@/util/utils"; import { useQuery } from "@tanstack/react-query"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { getClient } from "@/api/AxiosClient"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; @@ -96,6 +97,9 @@ function parseStatusParam(raw: string | null): Array { // Scheduled workflow runs carry a deterministic `wr_sched_` id prefix. function inferTriggerType(run: TaskRunListItem): TriggerType | null { + if (run.trigger_type) { + return run.trigger_type; + } if ( run.task_run_type === TaskRunType.WorkflowRun && run.run_id.startsWith("wr_sched_") @@ -105,9 +109,17 @@ function inferTriggerType(run: TaskRunListItem): TriggerType | null { return null; } -function getRunNavigationPath(run: TaskRunListItem): string { +function getRunNavigationPath( + run: TaskRunListItem, + studioEnabled: boolean, +): string { switch (run.task_run_type) { case TaskRunType.WorkflowRun: + // With the studio on, workflow runs open in its Run tab; otherwise they + // use the standalone run page (also the fallback when there is no wpid). + return studioEnabled && run.workflow_permanent_id + ? `/agents/${run.workflow_permanent_id}/studio?wr=${run.run_id}` + : `/runs/${run.run_id}`; case TaskRunType.TaskV2: return `/runs/${run.run_id}`; case TaskRunType.TaskV1: @@ -147,6 +159,7 @@ function RunHistory() { search: effectiveSearch, }); const navigate = useNavigate(); + const studioEnabled = useWorkflowStudioEnabled(); const { data: rawNextPageRuns } = useRunsQuery({ page: page + 1, @@ -183,28 +196,9 @@ function RunHistory() { const isNextDisabled = isFetching || !nextPageRuns || nextPageRuns.length === 0; - const { matchesParameter, isSearchActive } = - useKeywordSearch(debouncedSearch); - const { - expandedRows, - toggleExpanded: toggleParametersExpanded, - setAutoExpandedRows, - } = useParameterExpansion(); - - useEffect(() => { - if (!isSearchActive) { - setAutoExpandedRows([]); - return; - } - - const workflowRunIds = - runs - ?.filter((run) => run.task_run_type === TaskRunType.WorkflowRun) - .map((run) => run.run_id) - .filter((id): id is string => Boolean(id)) ?? []; - - setAutoExpandedRows(workflowRunIds); - }, [isSearchActive, runs, setAutoExpandedRows]); + const { matchesParameter } = useKeywordSearch(debouncedSearch); + const { expandedRows, toggleExpanded: toggleParametersExpanded } = + useParameterExpansion(); function handleNavigate(event: React.MouseEvent, path: string) { if (event.ctrlKey || event.metaKey) { @@ -258,7 +252,7 @@ function RunHistory() { ); const isWorkflowRun = run.task_run_type === TaskRunType.WorkflowRun; const isExpanded = isWorkflowRun && expandedRows.has(run.run_id); - const navPath = getRunNavigationPath(run); + const navPath = getRunNavigationPath(run, studioEnabled); const triggerType = inferTriggerType(run); const titleContent = @@ -408,11 +402,11 @@ function RunHistory() { description="Every time you run a workflow, the result shows up on this page. Create your first workflow to get started." primaryAction={{ label: "Create your first workflow", - onClick: () => navigate("/workflows"), + onClick: () => navigate("/agents"), }} secondaryAction={{ label: "Browse templates", - onClick: () => navigate("/workflows"), + onClick: () => navigate("/agents"), }} />
@@ -565,7 +559,7 @@ function WorkflowRunParametersInline({ workflowRunId, "params-inline", ], - queryFn: async () => { + queryFn: async ({ signal }) => { const client = await getClient(credentialGetter); const params = new URLSearchParams(); const isGlobalWorkflow = globalWorkflows?.some( @@ -577,6 +571,7 @@ function WorkflowRunParametersInline({ return client .get(`/workflows/${workflowPermanentId}/runs/${workflowRunId}`, { params, + signal, }) .then((r) => r.data); }, diff --git a/skyvern-frontend/src/routes/root/SideNav.test.tsx b/skyvern-frontend/src/routes/root/SideNav.test.tsx index abd02cbe5..8889a0cbf 100644 --- a/skyvern-frontend/src/routes/root/SideNav.test.tsx +++ b/skyvern-frontend/src/routes/root/SideNav.test.tsx @@ -130,7 +130,7 @@ describe("SideNav", () => { fireEvent.click(screen.getByTitle("Agents")); - expect(screen.getByTestId("location").textContent).toBe("/workflows"); + expect(screen.getByTestId("location").textContent).toBe("/agents"); }); it("starts recipes collapsed on short screens", () => { diff --git a/skyvern-frontend/src/routes/root/SideNav.tsx b/skyvern-frontend/src/routes/root/SideNav.tsx index 16d923502..4d6220718 100644 --- a/skyvern-frontend/src/routes/root/SideNav.tsx +++ b/skyvern-frontend/src/routes/root/SideNav.tsx @@ -15,6 +15,7 @@ import { ReaderIcon, ReloadIcon, Share1Icon, + TokensIcon, } from "@radix-ui/react-icons"; import { BagIcon } from "@/components/icons/BagIcon"; @@ -77,7 +78,7 @@ function SideNav({ collapsed }: Props = {}) { }, { label: "Agents", - to: "/workflows", + to: "/agents", icon: , children: [ { @@ -100,7 +101,7 @@ function SideNav({ collapsed }: Props = {}) { }, { label: "All Agents", - to: "/workflows", + to: "/agents", icon: , }, { @@ -264,9 +265,14 @@ function SideNav({ collapsed }: Props = {}) { icon: , children: [ { - label: "API Keys", - to: "/settings?section=api-keys", - icon: , + label: "General", + to: "/settings", + icon: , + }, + { + label: "Labels", + to: "/settings/labels", + icon: , }, ], }, diff --git a/skyvern-frontend/src/routes/root/useSidebarHidden.test.tsx b/skyvern-frontend/src/routes/root/useSidebarHidden.test.tsx new file mode 100644 index 000000000..c109ef326 --- /dev/null +++ b/skyvern-frontend/src/routes/root/useSidebarHidden.test.tsx @@ -0,0 +1,55 @@ +import { renderHook } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; + +import { useSidebarHidden } from "./useSidebarHidden"; + +function wrapper(initialEntry: string) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; +} + +describe("useSidebarHidden", () => { + it("hides the sidebar on editor routes under /agents", () => { + for (const path of [ + "/agents/wpid_1/edit", + "/agents/wpid_1/studio", + "/agents/wpid_1/build", + "/agents/wpid_1/wr_1/Login/build", + "/agents/wpid_1/debug", + ]) { + const { result } = renderHook(() => useSidebarHidden(), { + wrapper: wrapper(path), + }); + expect(result.current).toBe(true); + } + }); + + it("hides the sidebar at the legacy /workflows alias so nothing flashes mid-redirect", () => { + for (const path of ["/workflows/wpid_1/edit", "/workflows/wpid_1/studio"]) { + const { result } = renderHook(() => useSidebarHidden(), { + wrapper: wrapper(path), + }); + expect(result.current).toBe(true); + } + }); + + it("keeps the sidebar on list and run pages", () => { + for (const path of ["/agents", "/agents/wpid_1/runs", "/runs"]) { + const { result } = renderHook(() => useSidebarHidden(), { + wrapper: wrapper(path), + }); + expect(result.current).toBe(false); + } + }); + + it("hides the sidebar for embedded views regardless of route", () => { + const { result } = renderHook(() => useSidebarHidden(), { + wrapper: wrapper("/agents?embed=true"), + }); + expect(result.current).toBe(true); + }); +}); diff --git a/skyvern-frontend/src/routes/root/useSidebarHidden.ts b/skyvern-frontend/src/routes/root/useSidebarHidden.ts index 9b86e3db9..61209246c 100644 --- a/skyvern-frontend/src/routes/root/useSidebarHidden.ts +++ b/skyvern-frontend/src/routes/root/useSidebarHidden.ts @@ -1,5 +1,7 @@ import { useMatch, useSearchParams } from "react-router-dom"; +import { useAgentsPathMatch } from "@/routes/workflows/useAgentsPathMatch"; + type Options = { hideBrowserSessions?: boolean; }; @@ -7,14 +9,17 @@ type Options = { function useSidebarHidden({ hideBrowserSessions = false }: Options = {}) { const [searchParams] = useSearchParams(); const embed = searchParams.get("embed"); - const workflowEditMatch = useMatch("/workflows/:workflowPermanentId/edit"); - const workflowBuildMatch = useMatch("/workflows/:workflowPermanentId/build"); - const workflowBlockBuildMatch = useMatch( - "/workflows/:workflowPermanentId/:workflowRunId/:blockLabel/build", + const workflowEditMatch = useAgentsPathMatch("/:workflowPermanentId/edit"); + const workflowStudioMatch = useAgentsPathMatch( + "/:workflowPermanentId/studio", ); - const workflowDebugMatch = useMatch("/workflows/:workflowPermanentId/debug"); - const workflowBlockDebugMatch = useMatch( - "/workflows/:workflowPermanentId/:workflowRunId/:blockLabel/debug", + const workflowBuildMatch = useAgentsPathMatch("/:workflowPermanentId/build"); + const workflowBlockBuildMatch = useAgentsPathMatch( + "/:workflowPermanentId/:workflowRunId/:blockLabel/build", + ); + const workflowDebugMatch = useAgentsPathMatch("/:workflowPermanentId/debug"); + const workflowBlockDebugMatch = useAgentsPathMatch( + "/:workflowPermanentId/:workflowRunId/:blockLabel/debug", ); const browserSessionMatch = useMatch("/browser-session/:browserSessionId"); const nestedBrowserSessionMatch = useMatch( @@ -23,6 +28,7 @@ function useSidebarHidden({ hideBrowserSessions = false }: Options = {}) { return Boolean( workflowEditMatch || + workflowStudioMatch || workflowBuildMatch || workflowBlockBuildMatch || workflowDebugMatch || diff --git a/skyvern-frontend/src/routes/schedules/CreateOrgScheduleDialog.tsx b/skyvern-frontend/src/routes/schedules/CreateOrgScheduleDialog.tsx index 29b4548b7..301491974 100644 --- a/skyvern-frontend/src/routes/schedules/CreateOrgScheduleDialog.tsx +++ b/skyvern-frontend/src/routes/schedules/CreateOrgScheduleDialog.tsx @@ -26,6 +26,7 @@ import { getNextRuns, getTimezones, isValidCron, + meetsMinCronInterval, } from "@/routes/workflows/editor/panels/schedulePanel/cronUtils"; import { cn } from "@/util/utils"; import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery"; @@ -134,6 +135,8 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly) { }, [timezoneFilter, allTimezones]); const valid = isValidCron(cronExpression); + const intervalTooShort = valid && !meetsMinCronInterval(cronExpression); + const cronAccepted = valid && !intervalTooShort; const humanReadable = valid ? cronToHumanReadable(cronExpression) : null; const nextRuns = valid ? getNextRuns(cronExpression, timezone, 5) : []; @@ -152,7 +155,7 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly) { function handleSubmit() { if (!selectedWorkflow || !workflowDetailLoaded) return; const parametersValid = validateParameters(); - if (!valid || !parametersValid) return; + if (!cronAccepted || !parametersValid) return; const payload = buildScheduleParametersPayload( parameters, workflowParameters, @@ -300,7 +303,9 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly) { value={cronExpression} onChange={(e) => setCronExpression(e.target.value)} placeholder="* * * * *" - className={cn(!valid && cronExpression && "border-destructive")} + className={cn( + cronExpression && !cronAccepted && "border-destructive", + )} /> {humanReadable && (

{humanReadable}

@@ -310,6 +315,11 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly) { Invalid cron expression

)} + {intervalTooShort && ( +

+ Schedule runs must be at least 5 minutes apart. +

+ )}
{/* Timezone Selector */} @@ -383,7 +393,7 @@ function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly) {
{/* Timezone */} @@ -431,7 +448,7 @@ function ScheduleDetailPage() { + + + {error ?

{error}

: null} + + ); + } + + return ( +
+
+ + + {usageLabel(count)} + +
+
+ + + + + + + + + + +
+
+ ); +} + +function LabelManagement() { + const taggingEnabled = useFeatureFlag(WORKFLOW_TAGGING_FLAG) !== false; + const { data: tagValues = [], isPending } = useTagValuesListQuery({ + enabled: taggingEnabled, + }); + const [labelToDelete, setLabelToDelete] = React.useState( + null, + ); + const deleteMutation = useDeleteTagValueMutation(); + + const groups = React.useMemo(() => groupLabels(tagValues), [tagValues]); + const deleteCount = labelToDelete?.workflow_count ?? 0; + + function confirmDelete() { + if (!labelToDelete) { + return; + } + deleteMutation.mutate( + { key: labelToDelete.key, value: labelToDelete.value }, + { onSuccess: () => setLabelToDelete(null) }, + ); + } + + return ( +
+ + + Labels + + Manage the grouped labels used to organize your workflows. Rename, + recolor, or remove a label — changes apply everywhere it's used. + + + + {taggingEnabled && isPending ? ( +
+ + + +
+ ) : groups.length === 0 ? ( +

+ No labels yet. Grouped labels you add to workflows (group:label) + appear here for management. +

+ ) : ( +
+ {groups.map((group) => ( +
+
+

{group.key}

+ + {group.values.length} label + {group.values.length === 1 ? "" : "s"} + +
+
+ {group.values.map((label) => ( + + ))} +
+
+ ))} +
+ )} +
+
+ + { + if (!next && !deleteMutation.isPending) { + setLabelToDelete(null); + } + }} + > + + + Delete label “{labelToDelete?.value}”? + + This removes it from {usageLabel(deleteCount)} and from the “ + {labelToDelete?.key}” group. This can’t be undone. + + + + + + + + +
+ ); +} + +export { LabelManagement }; diff --git a/skyvern-frontend/src/routes/settings/Settings.tsx b/skyvern-frontend/src/routes/settings/Settings.tsx index e75b0190c..3a42b54a5 100644 --- a/skyvern-frontend/src/routes/settings/Settings.tsx +++ b/skyvern-frontend/src/routes/settings/Settings.tsx @@ -20,6 +20,7 @@ import { OnePasswordTokenForm } from "@/components/OnePasswordTokenForm"; import { BitwardenCredentialForm } from "@/components/BitwardenCredentialForm"; import { AzureClientSecretCredentialTokenForm } from "@/components/AzureClientSecretCredentialTokenForm"; import { CustomCredentialServiceConfigForm } from "@/components/CustomCredentialServiceConfigForm"; +import { CustomLLMConfigForm } from "@/components/CustomLLMConfigForm"; import { useVersionQuery } from "@/hooks/useVersionQuery"; import { formatVersion, getAppVersion } from "@/util/version"; @@ -125,6 +126,17 @@ function Settings() { + + + Custom LLMs + + Add custom OpenAI-compatible, Ollama, or OpenRouter models. + + + + + + {(getAppVersion() !== "development" || versionData?.version) && (

{getAppVersion() !== "development" && ( diff --git a/skyvern-frontend/src/routes/streaming/InteractiveStreamView.tsx b/skyvern-frontend/src/routes/streaming/InteractiveStreamView.tsx index b99bf31eb..6f98a149f 100644 --- a/skyvern-frontend/src/routes/streaming/InteractiveStreamView.tsx +++ b/skyvern-frontend/src/routes/streaming/InteractiveStreamView.tsx @@ -1,5 +1,5 @@ import type { RefObject } from "react"; -import { GlobeIcon } from "@radix-ui/react-icons"; +import { ExitIcon, GlobeIcon, HandIcon } from "@radix-ui/react-icons"; import { ZoomableImage } from "@/components/ZoomableImage"; import { Button } from "@/components/ui/button"; import { cn } from "@/util/utils"; @@ -21,6 +21,7 @@ interface InteractiveStreamViewProps { handleKeyUp: (e: React.KeyboardEvent) => void; }; currentUrl?: string; + centered?: boolean; } function UrlBar({ url }: { url: string }) { @@ -43,6 +44,7 @@ function InteractiveStreamView({ showControlButtons, handlers, currentUrl, + centered, }: InteractiveStreamViewProps) { const imgDataUrl = `data:image/${streamFormat};base64,${streamImgSrc}`; @@ -58,16 +60,23 @@ function InteractiveStreamView({ {currentUrl && } {showControlButtons && !userIsControlling && inputReady && (

-
)} {showControlButtons && userIsControlling && ( )} @@ -88,6 +97,23 @@ function InteractiveStreamView({ ); } + // Plain img (not ZoomableImage) so h-full resolves here; ZoomableImage's bare + // auto-height wrapper collapses the height and pins the frame to the top. + if (centered) { + return ( +
+ {currentUrl && } + +
+ ); + } + return (
{currentUrl && } diff --git a/skyvern-frontend/src/routes/tasks/create/PromptBox.test.tsx b/skyvern-frontend/src/routes/tasks/create/PromptBox.test.tsx index 977c6ac96..77a8a921b 100644 --- a/skyvern-frontend/src/routes/tasks/create/PromptBox.test.tsx +++ b/skyvern-frontend/src/routes/tasks/create/PromptBox.test.tsx @@ -23,6 +23,10 @@ const { mockNavigate, mockPost, mockSetAutoplay } = vi.hoisted(() => ({ mockSetAutoplay: vi.fn(), })); +const { studioState } = vi.hoisted(() => ({ + studioState: { enabled: false }, +})); + vi.mock("@/api/AxiosClient", () => ({ getClient: async () => ({ post: mockPost, @@ -33,6 +37,10 @@ vi.mock("@/hooks/useCredentialGetter", () => ({ useCredentialGetter: () => undefined, })); +vi.mock("@/hooks/useWorkflowStudioEnabled", () => ({ + useWorkflowStudioEnabled: () => studioState.enabled, +})); + vi.mock("@/store/useAutoplayStore", () => ({ useAutoplayStore: () => ({ setAutoplay: mockSetAutoplay, @@ -136,6 +144,8 @@ function renderPromptBox(enableCopilotHandoff = false) { afterEach(() => { cleanup(); + sessionStorage.clear(); + studioState.enabled = false; mockNavigate.mockReset(); mockPost.mockReset(); mockSetAutoplay.mockReset(); @@ -170,7 +180,7 @@ describe("PromptBox", () => { expect(body.request.url).toBe("https://google.com"); }); - test("hands Discover prompts to workflow copilot with agent execution", async () => { + test("hands Discover prompts to the legacy build path via route state only", async () => { mockPost.mockResolvedValue({ data: { workflow_permanent_id: "wpid_copilot", @@ -193,8 +203,45 @@ describe("PromptBox", () => { expect(path).toBe("/workflows"); expect(yaml).toContain("run_with: agent"); expect(yaml).not.toContain("run_with: code"); - expect(mockNavigate).toHaveBeenCalledWith("/workflows/wpid_copilot/build", { - state: { copilotMessage: "Build this workflow" }, + expect(mockNavigate).toHaveBeenCalledWith( + "/agents/wpid_copilot/build?via=discover", + { + state: { copilotMessage: "Build this workflow" }, + }, + ); + // The legacy /build (Debugger) surface never mounts the recovery hook, so + // no session-scoped copy is written — writing one would strand a dead entry. + expect( + sessionStorage.getItem("skyvern.discoverCopilotHandoff:wpid_copilot"), + ).toBeNull(); + }); + + test("hands Discover prompts to workflow studio with recoverable prompt state", async () => { + studioState.enabled = true; + mockPost.mockResolvedValue({ + data: { + workflow_permanent_id: "wpid_studio", + workflow_definition: { blocks: [] }, + }, }); + + renderPromptBox(true); + + fireEvent.change(screen.getByPlaceholderText("Enter your prompt..."), { + target: { value: "Build this in studio" }, + }); + fireEvent.click(screen.getByLabelText("submit-prompt")); + + await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1)); + + expect(mockNavigate).toHaveBeenCalledWith( + "/agents/wpid_studio/studio?via=discover", + { + state: { copilotMessage: "Build this in studio" }, + }, + ); + expect( + sessionStorage.getItem("skyvern.discoverCopilotHandoff:wpid_studio"), + ).toBe("Build this in studio"); }); }); diff --git a/skyvern-frontend/src/routes/tasks/create/PromptBox.tsx b/skyvern-frontend/src/routes/tasks/create/PromptBox.tsx index 8bbd27e64..c307eb06a 100644 --- a/skyvern-frontend/src/routes/tasks/create/PromptBox.tsx +++ b/skyvern-frontend/src/routes/tasks/create/PromptBox.tsx @@ -44,6 +44,9 @@ import { ImprovePrompt } from "@/components/ImprovePrompt"; import { SpeechInputButton } from "@/components/SpeechInputButton"; import { cn } from "@/util/utils"; import { useSpeechToTextField } from "@/hooks/useSpeechToTextField"; +import { useWorkflowStudioEnabled } from "@/hooks/useWorkflowStudioEnabled"; +import { rememberDiscoverCopilotPrompt } from "@/routes/workflows/discoverCopilotHandoff"; +import { workflowEditorPath } from "@/routes/workflows/studioNavigation"; const exampleCases = [ { @@ -139,6 +142,7 @@ function buildBlankWorkflowRequest( function PromptBox({ enableCopilotHandoff = false }: PromptBoxProps) { const navigate = useNavigate(); + const studioEnabled = useWorkflowStudioEnabled(); const [prompt, setPrompt] = useState(""); const credentialGetter = useCredentialGetter(); const queryClient = useQueryClient(); @@ -235,7 +239,9 @@ function PromptBox({ enableCopilotHandoff = false }: PromptBoxProps) { setAutoplay(workflow.workflow_permanent_id, firstBlock.label); } - navigate(`/workflows/${workflow.workflow_permanent_id}/build`); + navigate( + workflowEditorPath(workflow.workflow_permanent_id, studioEnabled), + ); }, onError: (error: AxiosError) => { toast({ @@ -275,9 +281,23 @@ function PromptBox({ enableCopilotHandoff = false }: PromptBoxProps) { onSuccess: ({ data: workflow, prompt }) => { queryClient.invalidateQueries({ queryKey: ["workflows"] }); queryClient.invalidateQueries({ queryKey: ["folders"] }); - navigate(`/workflows/${workflow.workflow_permanent_id}/build`, { - state: { copilotMessage: prompt }, - }); + // Only the studio handoff writes the recovery key. The legacy /build path + // can mount Workspace, but it never consumes this stored discover seed. + if (studioEnabled) { + rememberDiscoverCopilotPrompt(workflow.workflow_permanent_id, prompt); + } + // `?via=discover` is what makes WorkflowEditor fire + // `copilot.discover.started` with entry_point=discover on mount. + navigate( + workflowEditorPath( + workflow.workflow_permanent_id, + studioEnabled, + "?via=discover", + ), + { + state: { copilotMessage: prompt }, + }, + ); }, onError: (error: AxiosError) => { toast({ diff --git a/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.test.tsx b/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.test.tsx deleted file mode 100644 index c949db070..000000000 --- a/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.test.tsx +++ /dev/null @@ -1,141 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { type ActionsApiResponse, Status } from "@/api/types"; -import { ActionCardCompact } from "./ActionCardCompact"; - -function buildAction( - overrides: Partial = {}, -): ActionsApiResponse { - return { - action_id: "act_1", - action_type: "click", - status: Status.Completed, - task_id: "task_1", - step_id: "step_1", - step_order: 0, - action_order: 0, - confidence_float: 0.87, - description: null, - reasoning: "Click the submit button", - intention: null, - response: null, - created_by: null, - text: null, - ...overrides, - }; -} - -afterEach(() => { - cleanup(); -}); - -describe("ActionCardCompact", () => { - it("renders the action type label and reasoning preview", () => { - render( - {}} - onToggleExpanded={() => {}} - />, - ); - - expect(screen.getByText("Click")).toBeDefined(); - expect(screen.getByText("#3")).toBeDefined(); - expect(screen.getByText("Click the submit button")).toBeDefined(); - }); - - it("fires onToggleExpanded when the chevron is clicked and does not fire onSelect", () => { - const onSelect = vi.fn(); - const onToggleExpanded = vi.fn(); - render( - , - ); - - fireEvent.click(screen.getByRole("button", { name: /expand details/i })); - - expect(onToggleExpanded).toHaveBeenCalledTimes(1); - expect(onSelect).not.toHaveBeenCalled(); - }); - - it("fires onSelect when the row body is clicked", () => { - const onSelect = vi.fn(); - const onToggleExpanded = vi.fn(); - render( - , - ); - - fireEvent.click(screen.getByText("Click")); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onToggleExpanded).not.toHaveBeenCalled(); - }); - - it("renders reasoning inline, input in the expanded panel, and confidence as a chip", () => { - render( - {}} - onToggleExpanded={() => {}} - />, - ); - - // Reasoning is always inline now (not duplicated under a "Reasoning" heading) - expect(screen.getAllByText("Type the email address").length).toBe(1); - // Input value lives inside the expanded panel - expect(screen.getByText("Input")).toBeDefined(); - expect(screen.getByText("user@example.com")).toBeDefined(); - // Confidence renders as an inline chip in the top strip — no heading - expect(screen.getByText("92%")).toBeDefined(); - }); - - it("uses response as the input fallback when text is null for input_text", () => { - render( - {}} - onToggleExpanded={() => {}} - />, - ); - - expect(screen.getByText("from-script-value")).toBeDefined(); - }); -}); diff --git a/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.tsx b/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.tsx deleted file mode 100644 index 4793f220e..000000000 --- a/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { - type ActionsApiResponse, - type ActionType, - ActionTypes, - ReadableActionTypes, - Status, -} from "@/api/types"; -import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/util/utils"; -import { - ChevronDownIcon, - ChevronRightIcon, - CursorArrowIcon, - DoubleArrowDownIcon, - DownloadIcon, - DropdownMenuIcon, - HandIcon, - InputIcon, - KeyboardIcon, - LightningBoltIcon, - TimerIcon, - UploadIcon, -} from "@radix-ui/react-icons"; - -const actionIcons: Partial> = { - [ActionTypes.Click]: , - [ActionTypes.Hover]: , - [ActionTypes.InputText]: , - [ActionTypes.DownloadFile]: , - [ActionTypes.UploadFile]: , - [ActionTypes.SelectOption]: , - [ActionTypes.wait]: , - [ActionTypes.Scroll]: , - [ActionTypes.KeyPress]: , -}; - -type Props = { - action: ActionsApiResponse; - index: number; - active: boolean; - expanded: boolean; - onSelect: () => void; - onToggleExpanded: () => void; - cardClassName?: string; -}; - -function ActionCardCompact({ - action, - index, - active, - expanded, - onSelect, - onToggleExpanded, - cardClassName, -}: Props) { - // wait actions return ActionFailure despite succeeding - const success = - action.action_type === ActionTypes.wait || - action.status === Status.Completed || - action.status === Status.Skipped; - - const reasoningPreview = action.reasoning?.trim() ?? ""; - const fromScript = action.created_by === "script"; - const icon = actionIcons[action.action_type] ?? null; - const label = ReadableActionTypes[action.action_type]; - const confidencePct = - action.confidence_float != null - ? Math.round(action.confidence_float * 100) - : null; - // script-generated input text lives in action.response, not action.text - const inputValue = - action.action_type === ActionTypes.InputText - ? (action.text ?? action.response) - : null; - - // Only the input value is worth hiding behind a chevron — it can be long - // or sensitive. Confidence is short metadata so we render it inline. - const hasExpandableDetail = inputValue != null && inputValue.length > 0; - - return ( - -
- - {hasExpandableDetail && ( - // Sibling button (not nested) so the outer select button doesn't - // contain interactive content. Absolute-positioned to keep the - // visual chevron in the top-right corner of the card. - - )} - - {inputValue != null && inputValue.length > 0 && ( -
-
- Input -
-
- {inputValue} -
-
- )} -
-
-
- ); -} - -export { ActionCardCompact }; diff --git a/skyvern-frontend/src/routes/tasks/detail/RunViewingModeToggle.tsx b/skyvern-frontend/src/routes/tasks/detail/RunViewingModeToggle.tsx deleted file mode 100644 index 9bf425ffc..000000000 --- a/skyvern-frontend/src/routes/tasks/detail/RunViewingModeToggle.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useRunViewingPreferenceStore } from "@/store/RunViewingPreferenceStore"; -import { cn } from "@/util/utils"; -import { RowsIcon, ViewVerticalIcon } from "@radix-ui/react-icons"; - -type ModeButtonProps = { - active: boolean; - label: string; - onClick: () => void; - children: React.ReactNode; -}; - -function ModeButton({ active, label, onClick, children }: ModeButtonProps) { - return ( - - - - - {label} - - ); -} - -function RunViewingModeToggle() { - const viewMode = useRunViewingPreferenceStore((s) => s.viewMode); - const setViewMode = useRunViewingPreferenceStore((s) => s.setViewMode); - - return ( - -
- setViewMode("compact")} - > - - - setViewMode("detailed")} - > - - -
-
- ); -} - -export { RunViewingModeToggle }; diff --git a/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.test.tsx b/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.test.tsx index e968d6fcd..989d4b299 100644 --- a/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.test.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.test.tsx @@ -1,35 +1,9 @@ // @vitest-environment jsdom import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -const { storage } = vi.hoisted(() => { - const storage = new Map(); - const localStorageMock = { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => { - storage.set(key, value); - }, - removeItem: (key: string) => { - storage.delete(key); - }, - clear: () => { - storage.clear(); - }, - key: (index: number) => Array.from(storage.keys())[index] ?? null, - get length() { - return storage.size; - }, - }; - Object.defineProperty(globalThis, "localStorage", { - value: localStorageMock, - configurable: true, - writable: true, - }); - return { storage }; -}); - vi.mock("@/api/AxiosClient", () => ({ getClient: async () => ({ get: async () => ({ data: null }), @@ -40,19 +14,7 @@ vi.mock("@/hooks/useCredentialGetter", () => ({ useCredentialGetter: () => undefined, })); -const { isViewingV2Mock } = vi.hoisted(() => ({ - isViewingV2Mock: vi.fn(() => false), -})); - -vi.mock("@/hooks/useWorkflowRunViewingV2", () => ({ - useWorkflowRunViewingV2: () => isViewingV2Mock(), -})); - import { Action, ActionTypes } from "@/api/types"; -import { - RUN_VIEWING_STORAGE_KEY, - useRunViewingPreferenceStore, -} from "@/store/RunViewingPreferenceStore"; import { ScrollableActionList } from "./ScrollableActionList"; function buildAction(overrides: Partial = {}): Action { @@ -70,7 +32,7 @@ function buildAction(overrides: Partial = {}): Action { }; } -function renderList(extraProps?: { activeIndex?: number | "stream" }) { +function renderList() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -78,7 +40,7 @@ function renderList(extraProps?: { activeIndex?: number | "stream" }) { {}} showStreamOption={false} taskDetails={{ actions: 1, steps: 1 }} @@ -87,43 +49,15 @@ function renderList(extraProps?: { activeIndex?: number | "stream" }) { ); } -beforeEach(() => { - storage.clear(); - useRunViewingPreferenceStore.setState({ viewMode: "compact" }, false); - isViewingV2Mock.mockReset(); - isViewingV2Mock.mockReturnValue(false); -}); - afterEach(() => { cleanup(); }); -describe("ScrollableActionList view-mode swap", () => { - it("renders the legacy detailed card when the v2 flag is off", () => { +describe("ScrollableActionList", () => { + it("renders the action card with its reasoning", () => { renderList(); - expect(screen.queryByLabelText("Compact view")).toBeNull(); - expect(screen.queryByLabelText("Detailed view")).toBeNull(); - expect( - document.querySelector('[data-slot="action-card-compact"]'), - ).toBeNull(); // getByText throws if not found, so reaching this line means it rendered screen.getByText("Click the submit button"); }); - - it("renders compact cards and the toggle when v2 flag on + viewMode=compact", () => { - isViewingV2Mock.mockReturnValue(true); - useRunViewingPreferenceStore.setState({ viewMode: "compact" }, false); - - renderList(); - - // getByLabelText throws if not found - screen.getByLabelText("Compact view"); - screen.getByLabelText("Detailed view"); - expect( - document.querySelector('[data-slot="action-card-compact"]'), - ).not.toBeNull(); - // sanity: persistence path is wired - expect(RUN_VIEWING_STORAGE_KEY).toBe("skyvern.runViewing"); - }); }); diff --git a/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx b/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx index 81329600c..6a71240c7 100644 --- a/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx @@ -1,5 +1,5 @@ import { getClient } from "@/api/AxiosClient"; -import { Action, ActionsApiResponse, ActionTypes, Status } from "@/api/types"; +import { Action, ActionTypes } from "@/api/types"; import { StatusPill } from "@/components/ui/status-pill"; import { Tooltip, @@ -10,8 +10,6 @@ import { import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; -import { useWorkflowRunViewingV2 } from "@/hooks/useWorkflowRunViewingV2"; -import { useRunViewingPreferenceStore } from "@/store/RunViewingPreferenceStore"; import { cn } from "@/util/utils"; import { CheckCircledIcon, @@ -20,33 +18,8 @@ import { LightningBoltIcon, } from "@radix-ui/react-icons"; import { useQueryClient } from "@tanstack/react-query"; -import { ReactNode, useRef, useState } from "react"; -import { ActionCardCompact } from "./ActionCardCompact"; +import { ReactNode, useRef } from "react"; import { ActionTypePill } from "./ActionTypePill"; -import { RunViewingModeToggle } from "./RunViewingModeToggle"; - -function toActionsApiResponse( - action: Action, - index: number, -): ActionsApiResponse { - return { - action_id: `${action.stepId}-${index}`, - action_type: action.type, - status: action.success ? Status.Completed : Status.Failed, - task_id: null, - step_id: action.stepId, - step_order: null, - action_order: index, - confidence_float: action.confidence ?? null, - description: null, - reasoning: action.reasoning, - intention: null, - response: null, - created_by: action.created_by, - text: action.input ?? null, - screenshot_artifact_id: action.screenshotArtifactId ?? null, - }; -} type Props = { data: Array; @@ -69,15 +42,10 @@ function ScrollableActionList({ }: Props) { const queryClient = useQueryClient(); const credentialGetter = useCredentialGetter(); - const isViewingV2 = useWorkflowRunViewingV2(); - const viewMode = useRunViewingPreferenceStore((s) => s.viewMode); - const [expandedActionId, setExpandedActionId] = useState(null); const refs = useRef>( Array.from({ length: data.length + 1 }), ); - const useCompact = isViewingV2 && viewMode === "compact"; - function prefetchStepArtifacts(stepId: string) { queryClient.prefetchQuery({ queryKey: ["step", stepId, "artifacts"], @@ -98,34 +66,6 @@ function ScrollableActionList({ continue; } const selected = activeIndex === i; - if (useCompact) { - const compactAction = toActionsApiResponse(action, i); - elements.push( -
{ - refs.current[i] = element; - }} - onMouseEnter={() => prefetchStepArtifacts(action.stepId)} - > - onActiveIndexChange(i)} - onToggleExpanded={() => - setExpandedActionId((current) => - current === compactAction.action_id - ? null - : compactAction.action_id, - ) - } - /> -
, - ); - continue; - } elements.push(
Steps: {taskDetails.steps}
- {isViewingV2 && }
@@ -217,9 +156,7 @@ function ScrollableActionList({ refs.current[data.length] = element; }} className={cn( - useCompact - ? "flex cursor-pointer items-center gap-2 rounded-md border-2 bg-slate-elevation3 px-3 py-2 text-xs hover:border-slate-50" - : "flex cursor-pointer rounded-lg border-2 bg-slate-elevation3 p-4 hover:border-slate-50", + "flex cursor-pointer rounded-lg border-2 bg-slate-elevation3 p-4 hover:border-slate-50", { "border-slate-50": activeIndex === "stream", }, @@ -227,15 +164,8 @@ function ScrollableActionList({ onClick={() => onActiveIndexChange("stream")} >
- - - Live - + + Live
)} diff --git a/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.test.tsx b/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.test.tsx new file mode 100644 index 000000000..bd0b84cc8 --- /dev/null +++ b/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom + +vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() })); +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); +vi.mock("./StepArtifacts", () => ({ + StepArtifacts: ({ + id, + stepProps, + }: { + id: string; + stepProps: StepApiResponse; + }) => ( +
+ {id}:{stepProps.order} +
+ ), +})); +vi.mock("./StepNavigation", () => ({ + StepNavigation: ({ activeIndex }: { activeIndex: number }) => ( +
{activeIndex}
+ ), +})); + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getClient } from "@/api/AxiosClient"; +import { Status, type StepApiResponse } from "@/api/types"; +import { StepArtifactsLayout } from "./StepArtifactsLayout"; + +const mockedGetClient = vi.mocked(getClient); + +function buildStep(overrides: Partial = {}): StepApiResponse { + return { + step_id: "stp_0", + task_id: "tsk_123", + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", + input_token_count: 0, + is_last: false, + order: 0, + organization_id: "org_123", + retry_index: 0, + status: Status.Completed, + step_cost: 0, + ...overrides, + }; +} + +function renderLayout(initialEntry: string, steps: Array) { + mockedGetClient.mockResolvedValue({ + get: vi.fn().mockResolvedValue({ data: steps }), + } as never); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + render( + + + + } + /> + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("StepArtifactsLayout", () => { + it("selects the diagnostic step by step_id when present", async () => { + renderLayout("/tasks/tsk_123/diagnostics?step_id=stp_2", [ + buildStep({ step_id: "stp_1", order: 0 }), + buildStep({ step_id: "stp_2", order: 1 }), + ]); + + expect((await screen.findByTestId("active-step")).textContent).toBe( + "stp_2:1", + ); + expect(screen.getByTestId("active-step-index").textContent).toBe("1"); + }); + + it("falls back to the first step instead of rendering blank diagnostics for an out-of-range step", async () => { + renderLayout("/tasks/tsk_123/diagnostics?step=99", [ + buildStep({ step_id: "stp_1", order: 0 }), + buildStep({ step_id: "stp_2", order: 1 }), + ]); + + expect((await screen.findByTestId("active-step")).textContent).toBe( + "stp_1:0", + ); + expect(screen.getByTestId("active-step-index").textContent).toBe("0"); + }); + + it("uses the step index fallback when step_id does not resolve for a retried step", async () => { + renderLayout("/tasks/tsk_123/diagnostics?step_id=stp_stale&step=2", [ + buildStep({ step_id: "stp_order_0", order: 0 }), + buildStep({ + step_id: "stp_order_1_original", + order: 1, + retry_index: 0, + }), + buildStep({ + step_id: "stp_order_1_retry", + order: 1, + retry_index: 1, + }), + ]); + + expect((await screen.findByTestId("active-step")).textContent).toBe( + "stp_order_1_retry:1", + ); + expect(screen.getByTestId("active-step-index").textContent).toBe("2"); + }); +}); diff --git a/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.tsx b/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.tsx index a1ca5a9e6..5d72a376a 100644 --- a/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/StepArtifactsLayout.tsx @@ -8,9 +8,29 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { apiPathPrefix } from "@/util/env"; import { useFirstParam } from "@/hooks/useFirstParam"; +function getRequestedStepIndex(stepParam: string | null): number { + const step = Number(stepParam); + return Number.isInteger(step) && step >= 0 ? step : 0; +} + +function getActiveStepIndex( + steps: Array | undefined, + stepParam: string | null, + stepIdParam: string | null, +): number { + const requestedStepIndex = getRequestedStepIndex(stepParam); + if (!steps) return requestedStepIndex; + + if (stepIdParam) { + const stepIdIndex = steps.findIndex((step) => step.step_id === stepIdParam); + if (stepIdIndex !== -1) return stepIdIndex; + } + + return requestedStepIndex < steps.length ? requestedStepIndex : 0; +} + function StepArtifactsLayout() { const [searchParams, setSearchParams] = useSearchParams(); - const step = Number(searchParams.get("step")) || 0; const credentialGetter = useCredentialGetter(); const taskId = useFirstParam("taskId", "runId"); @@ -32,18 +52,24 @@ function StepArtifactsLayout() { return
Error: {error?.message}
; } - const activeStep = steps?.[step]; + const activeStepIndex = getActiveStepIndex( + steps, + searchParams.get("step"), + searchParams.get("step_id"), + ); + const activeStep = steps?.[activeStepIndex]; return (