Compare commits

...

242 commits

Author SHA1 Message Date
Andrew Neilson
0b0fce76e7
fix(SKY-12102): key copilot code-block synthesis on captured control-state (#7254)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-09 17:02:55 -07:00
pedrohsdb
24863e8505
feat(llm): CONTENT_FILTER_RESCUE_LLM_NAME flag picks the content-filter rescue model (SKY-11766) (#7253) 2026-07-09 16:56:11 -07:00
Marc Kelechava
ad1b50cf58
test: make embedded E2E tests opt-in via SKYVERN_EMBEDDED_E2E (#7252) 2026-07-09 16:41:42 -07:00
Marc Kelechava
806977ece8
feat(SKY-12100): lead session MCP results with app_url + recording_url (#7251) 2026-07-09 16:41:29 -07:00
Marc Kelechava
962acb3cdd
fix(SKY-12101): persist observe refs per browser session for cross-call execute (#7250) 2026-07-09 16:40:08 -07:00
pedrohsdb
4d2aeda083
feat(SKY-11672): schema-first storage for heal episodes and heal proposals (#7249) 2026-07-09 16:00:52 -07:00
pedrohsdb
38b178f22f
feat(SKY-12053): path-neutral heal chokepoint for both code-block engines (#7248) 2026-07-09 15:02:31 -07:00
Andrew Neilson
9b18e40d45
fix(SKY-12069): fail closed when zero requested-output criteria grade the deliverable (#7247) 2026-07-09 14:51:18 -07:00
pedrohsdb
0ee505b24b
feat(SKY-11677): navigate dead-nav self-heal seats to the authored goto target (#7246) 2026-07-09 14:28:09 -07:00
pedrohsdb
295d1935f5
feat(SKY-11673): browser-adoption seam for runtime self-heal turns (#7245) 2026-07-09 13:05:45 -07:00
Andrew Neilson
f2acc92939
fix(SKY-12073): make the copilot repair ceiling reachable after a failed run (#7244) 2026-07-09 12:51:10 -07:00
Andrew Neilson
c269e36c16
fix(SKY-11539): resolve exactly-named org credential regardless of classifier status/kind (#7243) 2026-07-09 12:32:13 -07:00
Aaron Perez
55709c945e
fix(SKY-12019): guard clear/fill from iframe targets (#7242) 2026-07-09 13:29:31 -05:00
Andrew Neilson
9e49dff964
feat(SKY-11859): reach positive page-evidence carrier on dispatched/STANDARD copilot runs (#7241) 2026-07-09 11:08:56 -07:00
Shuchang Zheng
f09632c51c
fix(workflow-editor): wait/code block fields, breakout button, last-run values (#7239) 2026-07-09 10:52:03 -07:00
Aaron Perez
95e57cf43d
SKY-12058: Distinguish MCP trigger type (#7236) 2026-07-09 12:21:43 -05:00
Shuchang Zheng
b4e6c8f1cc
fix(schedules): preserve paused state on edit, stricter cron validation, schedule UX (#7235) 2026-07-09 10:16:08 -07:00
Celal Zamanoğlu
e9ef9529ca
feat(SKY-11973): copilot narrates studio runs happening next to it (#7233) 2026-07-09 19:31:46 +03:00
Celal Zamanoğlu
3aaffb667d
feat(SKY-11339): progressive action reveal for copilot verify cards (#7232)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-09 18:57:07 +03:00
Celal Zamanoğlu
59fd1982d8
refactor(SKY-11967): remove internal run-viewing toggle + orphaned compact action list (#7231) 2026-07-09 16:56:26 +03:00
Shuchang Zheng
e174afd63f
chore: remove deprecated LangChain and LlamaIndex integrations (#7095)
Some checks are pending
Auto Create GitHub Release on Version Change / check-version-change (push) Waiting to run
Auto Create GitHub Release on Version Change / create-release (push) Blocked by required conditions
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Build Skyvern SDK and publish to PyPI / check-version-change (push) Waiting to run
Build Skyvern SDK and publish to PyPI / run-ci (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / build-sdk (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / publish-sdk (push) Blocked by required conditions
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 06:38:51 +00:00
Aaron Perez
edf38e68fc
security: fix cryptography HIGH vulnerability in integrations/llama_index (#6935)
Co-authored-by: claude[bot] <claude-bot@skyvern.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 06:21:08 +00:00
Shuchang Zheng
bf3624d788
fix(SKY-11996): restore copilot actuation-obligation feature reverted by #13210 (#7230) 2026-07-08 23:00:59 -07:00
Shuchang Zheng
d79359d55c
security: restore jupyter-server >=2.20.0 and msgpack >=1.2.1 (regressed by SKY-12044) (#7229) 2026-07-08 23:00:35 -07:00
Aaron Perez
7b4dd7e7c2
security: fix vite HIGH-severity fs.deny bypass (alert #720) (#6772)
Co-authored-by: Claude Security Bot <security-scan@claude.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
2026-07-09 05:39:08 +00:00
Aaron Perez
7120df409d
security: bump form-data to 4.0.6 in skyvern-frontend (#6912)
Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
2026-07-09 05:22:18 +00:00
Aaron Perez
97da02c149
security: pin undici to ^7.28.0 in skyvern-frontend (fixes 2 HIGH alerts) (#6958)
Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
2026-07-09 05:07:29 +00:00
Andrew Neilson
65d28b1666
SKY-12070 mint requested-output criteria for quoted fields (#7227) 2026-07-08 20:54:49 -07:00
Andrew Neilson
33ee848e01
fix(SKY-12044): resolve in-process page.extract() via SkyvernPage recorder wrapper (#7226) 2026-07-08 20:52:08 -07:00
Aaron Perez
3e260f21bf
security: bump jupyter-server >=2.20.0 and msgpack >=1.2.1 (#6910)
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 03:12:36 +00:00
Andrew Neilson
6a5226d197
feat(SKY-11897): typed judgment-evidence grounding — confirm/refute/abstain from independent packet (#7224) 2026-07-08 19:53:47 -07:00
Andrew Neilson
bb7fe7158f
fix(SKY-11958): fail closed on ambiguous output-contract owner resolution (#7223) 2026-07-08 19:09:29 -07:00
Aaron Perez
8b78d8e8f1
fix(SKY-10708): thread loop value into cached-block agent fallback payload (#7221) 2026-07-08 20:02:20 -05:00
Marc Kelechava
633b65d33d
SKY-11940 MCP browser tool schemas: selector/ref leads, intent is the explicit AI fallback (#7220) 2026-07-08 17:11:30 -07:00
Shuchang Zheng
26f73ec044
fix(SKY-12078): restore run-level editable credential input for single-credential login blocks (#7219)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-08 16:42:42 -07:00
Aaron Perez
7681a7cd2b
[NO-TICKET] Delete tmp directory (#7217) 2026-07-08 22:32:16 +00:00
Aaron Perez
ac7ffaf6eb
fix(SKY-11623): preserve /discover prompt in Copilot handoff (#7216) 2026-07-08 17:05:56 -05:00
Andrew Neilson
d72b6a1ba0
SKY-11911 scout fill carry rebind (#7215) 2026-07-08 14:40:40 -07:00
Andrew Neilson
400fd43d7b
SKY-11950 typed degraded completion terminal (#7214) 2026-07-08 14:32:07 -07:00
Marc Kelechava
c2038aaafc
docs(mcp): add Claude Desktop bundle release runbook (#7213) 2026-07-08 14:31:35 -07:00
Aaron Perez
ac01aca99c
Fix staging preview CORS allowlist (SKY-12074) (#7212) 2026-07-08 16:19:00 -05:00
Shuchang Zheng
265679e9dd
fix: log BlockedHost download rejections at warning instead of error (#7211) 2026-07-08 13:52:39 -07:00
Aaron Perez
2452bf60e1
fix(SKY-12018): classify external download HTTP errors (#7210) 2026-07-08 14:32:38 -05:00
Aaron Perez
777e842c39
feat(SKY-11909): /analytics console restructure — Phase 1 (v4.2) (#7209) 2026-07-08 14:19:50 -05:00
Suchintan
337c002595
ci(SKY-12002): unbind pytest-shard critical path from two outlier tests (#7208)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-08 15:08:02 -04:00
Marc Kelechava
f339ff76da
chore(mcpb): rebuild skyvern-claude-desktop.mcpb v1.0.46 (#7207) 2026-07-08 12:00:20 -07:00
Aaron Perez
a3ce97384c
Add run tag frontend hooks (#7205) 2026-07-08 13:58:48 -05:00
Aaron Perez
1eb2e64f03
Fix workflow list query alias (SKY-12016) (#7206) 2026-07-08 13:37:57 -05:00
Aaron Perez
78fa30ed11
Add workflow run tag event data layer (#7204) 2026-07-08 13:12:20 -05:00
Marc Kelechava
bee2b836ce
fix(mcp): route empty-API-key 401s to the Configure dialog instead of OAuth (#7203) 2026-07-08 10:41:16 -07:00
Celal Zamanoğlu
323adca6a1
fix(SKY-12021): show user-inputted prompt for file download block (#7202)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-08 19:38:57 +03:00
Celal Zamanoğlu
bff497e2e5
fix(SKY-11991): expand collapsed loop/conditional ancestors before block jump (#7201)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-08 19:38:07 +03:00
Celal Zamanoğlu
9949fb0ded
feat(SKY-11988): add credentialPrompt signal to copilot narrative payload (#7200)
Some checks failed
Auto Create GitHub Release on Version Change / check-version-change (push) Waiting to run
Auto Create GitHub Release on Version Change / create-release (push) Blocked by required conditions
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Build Skyvern SDK and publish to PyPI / check-version-change (push) Waiting to run
Build Skyvern SDK and publish to PyPI / run-ci (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / build-sdk (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / publish-sdk (push) Blocked by required conditions
Build Skyvern TS SDK and publish to npm / check-version-change (push) Has been cancelled
Build Skyvern TS SDK and publish to npm / build-and-publish-sdk (push) Has been cancelled
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-08 18:24:49 +03:00
Shuchang Zheng
87303f70b1
Bump version to 1.0.46 (#7199)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 14:01:41 +00:00
Shuchang Zheng
fa5bef92f3
fix: log expected goal-complete check failures at warning, not error (#7198)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-08 06:34:34 -07:00
Shuchang Zheng
abaf77f41b
fix: log page-analysis timeouts at warning instead of error (#7197)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-08 06:34:21 -07:00
Shuchang Zheng
29a100956f
fix(SKY-11848): reap stale PSM v2 adoption cache entries to stop API pod driver leaks (#7196)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-08 06:32:51 -07:00
Aaron Perez
cedd5a9c1c
fix(SKY-11912): guard server-side fetch URLs against SSRF (#7173)
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
2026-07-08 08:11:20 -05:00
Aaron Perez
30674711a7
fix(SKY-12013): return missing dropdown target failures (#7195) 2026-07-08 03:32:57 -05:00
Andrew Neilson
3fc353fe04
Add workflow-copilot lite LLM env key (#7194)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-07 23:46:19 -07:00
LawyZheng
c78637ae87
fix(SKY-11992): capture blob: URL downloads in the CDP download monitor (#7192)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-08 13:39:27 +08:00
Andrew Neilson
b7c1383f81
SKY-11498: Route request-policy classifier through lite Copilot model setting (#7191) 2026-07-07 21:20:33 -07:00
Andrew Neilson
95d1e966fa
fix(SKY-11948): consume output-contract grants at dispatch (#7188) 2026-07-07 20:37:34 -07:00
Aaron Perez
5142883ed6
fix(SKY-11997,SKY-12004): name session-scoped downloads by configured download_suffix (#7172)
Some checks are pending
Auto Create GitHub Release on Version Change / check-version-change (push) Waiting to run
Auto Create GitHub Release on Version Change / create-release (push) Blocked by required conditions
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Build Skyvern SDK and publish to PyPI / check-version-change (push) Waiting to run
Build Skyvern SDK and publish to PyPI / run-ci (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / build-sdk (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / publish-sdk (push) Blocked by required conditions
Build Skyvern TS SDK and publish to npm / check-version-change (push) Waiting to run
Build Skyvern TS SDK and publish to npm / build-and-publish-sdk (push) Blocked by required conditions
2026-07-07 18:25:29 -05:00
Shuchang Zheng
632b8ec501
Bump version to 1.0.45 (#7167)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:18:31 +00:00
Suchintan
d0094d3568
feat(SKY-8207): consistent egress IP for Save & Reuse Session runs (#7169)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 17:45:11 -04:00
Marc Kelechava
147eea686f
chore(SKY-12003): cap fastmcp below 3.4 as temporary safety upper bound (#7168)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 14:24:00 -07:00
Marc Kelechava
caf7420309
fix(SKY-12003): restore hosted /mcp/ connectivity and freeze Docker deps to uv.lock (#7166)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 14:04:05 -07:00
Marc Kelechava
e7a57dd653
fix(SKY-11910): surface custom dropdown options in MCP observe + non-leaking action errors (#7165)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 13:06:12 -07:00
pedrohsdb
ab94965186
fix(llm): re-point Vertex gemini-3.1-flash-lite at the GA id (preview sunsets 2026-07-09) (#7162)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 12:14:46 -07:00
Marc Kelechava
ee0964b43a
fix(SKY-11965): report valid PostgreSQL start command in skyvern status (#7161)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 12:04:47 -07:00
Celal Zamanoğlu
d0614586f2
fix(credentials): close secret-leak gap in temp-credential cleanup + regression test (#7160)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 21:17:16 +03:00
Aaron Perez
f939ec8d32
perf(SKY-8227): re-add DB pool timeout/recycle knobs and API-only pool sizing (#7159) 2026-07-07 13:16:38 -05:00
Suchintan
f920d193eb
feat(SKY-11993): warn that PDF Fill works best on fillable PDFs (#7158)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 12:41:32 -04:00
Shuchang Zheng
e116aaf35c
fix(credentials): edit-test corruption + browser-profile name reuse (partial unique index) (#7157)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-07 08:58:42 -07:00
Aaron Perez
e2edbd9aea
fix(SKY-11982): restore real filenames for downloaded files (upload regression) (#7156) 2026-07-07 10:41:51 -05:00
Shuchang Zheng
90e29f9fc9
fix(llm): fall back to non-Gemini model on Gemini content_filter (SKY-11766) (#7155)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-07 07:52:03 -07:00
Aaron Perez
c14b6cb95f
fix(llm): inject Gemini safety_settings per-deployment so Azure fallback survives (SKY-11766, incident #646) (#7154) 2026-07-07 09:05:14 -05:00
LawyZheng
368d4a5453
fix: broaden CDP download interceptor to capture XHR file responses with generic binary MIME types (#7153) 2026-07-07 20:00:07 +08:00
Aaron Perez
4c4db2a1c3
fix(SKY-11916): tighten cloud credentialed CORS (#7151) 2026-07-07 04:45:35 -05:00
Andrew Neilson
f22243ea3f
fix(SKY-11899): Copilot fresh-session replay frontier (#7149) 2026-07-07 00:30:42 -07:00
LawyZheng
45505ef3e1
feat(SKY-11609): attribute Temporal activity-timeout workflow finalization (#7148) 2026-07-07 15:07:41 +08:00
Shuchang Zheng
ef9476e8fa
security: add .npmrc ignore-scripts to skyvern-ts/client (#7094)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-07 03:15:52 +00:00
Aaron Perez
298ab5550c
fix(SKY-11881): persist typed code-block recorder actions (#7147) 2026-07-06 21:27:07 -05:00
Marc Kelechava
754751559a
SKY-11527: scope custom-select hard-fail to checkbox/radio + settle async read-back (#7146) 2026-07-06 18:14:12 -07:00
Aaron Perez
75df48f09e
fix(SKY-11917): validate script deploy file paths (#7145) 2026-07-06 20:13:38 -05:00
Marc Kelechava
fe5f183952
SKY-11527 Collapse custom select fanout (#7143) 2026-07-06 17:10:12 -07:00
Aaron Perez
af2a0ffcbd
fix(SKY-11914): require auth for pylon email hash (#7142) 2026-07-06 18:20:57 -05:00
Andrew Neilson
3c2c44c11a
feat(SKY-11879): family-uniform output-contract actuation — no bail returns unchanged (#7141) 2026-07-06 15:48:38 -07:00
Aaron Perez
9089feadc7
Revert "feat(editor): highlight run-blocking blocks and gate the Run CTA (SKY-10569)" (#7140)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Build Skyvern TS SDK and publish to npm / check-version-change (push) Waiting to run
Build Skyvern TS SDK and publish to npm / build-and-publish-sdk (push) Blocked by required conditions
2026-07-06 17:35:55 -05:00
Aaron Perez
ba2ec32e15
feat(editor): highlight run-blocking blocks and gate the Run CTA (SKY-10569) (#7139) 2026-07-06 17:10:22 -05:00
Aaron Perez
d723de621d
fix(SKY-11793): remove non-sandboxed second render in TextPromptBlock (SSTI) (#7137) 2026-07-06 16:44:03 -05:00
Aaron Perez
4d06850f1c
SKY-11575 Restore unified runs toolbar with gesture zoom and pulse feedback (#7136) 2026-07-06 16:28:36 -05:00
Andrew Neilson
fdfaf3bbf7
SKY-11865: mint-time satisfiability + typed fail-safe degrade on the classifier-fallback completion producer (#7135)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-06 14:07:28 -07:00
Aaron Perez
d71ea50d9a
security: update cryptography to fix CVE-2024-12797 in langchain integration (#7012)
Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 20:38:18 +00:00
Aaron Perez
bcc765f15c
security: fix 7 HIGH npm vulnerabilities in skyvern-ts/client (vite, fast-uri, rollup, serialize-javascript) (#7082)
Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
2026-07-06 20:10:56 +00:00
Suchintan
453db78251
test(SKY-11847): table-drive CDP download interceptor tests (67→34, coverage-identical) (#7134)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-06 14:47:53 -04:00
Suchintan
2bd0c07665
test(SKY-11847): matrix workflow-parameter-validation tests (65→28, coverage-identical) (#7133)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-06 14:44:03 -04:00
Celal Zamanoğlu
f4bdd49b0a
fix(SKY-11500): drop the throwaway default-tab recording (#7123) 2026-07-06 19:37:21 +03:00
Celal Zamanoğlu
43cf66c145
feat(SKY-11471): prefill login block goal from saved credential instructions (#7122) 2026-07-06 19:22:35 +03:00
LawyZheng
7bd572c230
Add vendor worker queues with DYNAMIC_BROWSER_TYPE routing (#7121) 2026-07-07 00:13:36 +08:00
Shuchang Zheng
552f0dd437
fix(llm): disable Gemini safety filters for agent calls (SKY-11766) (#7120) 2026-07-06 09:03:29 -07:00
Suchintan
4dc6094473
test(SKY-11847): case-table the script-reviewer validator tests (118→46, coverage-identical) (#7119)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-06 11:42:47 -04:00
Celal Zamanoğlu
7eecd12f82
feat(SKY-11891): restore learned run-class studio pane layout (#7118) 2026-07-06 18:35:55 +03:00
Suchintan
8f61fe84ea
test(SKY-11847): consolidate failure-classifier tests to a category matrix (66→15, coverage-identical) (#7117)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-06 11:12:31 -04:00
Suchintan
5106524ed7
test(SKY-11847): it.each the FE css-vars/datadog-filter/tagTypes suites (84 to 39 it blocks, cases preserved) (#7115)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-06 11:10:55 -04:00
Suchintan
eeec9f38b9
test(SKY-11847): matrix extraction-cache identity tests (80→33, coverage-identical) (#7116)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-06 11:10:51 -04:00
Suchintan
ec773e02d6
feat(SKY-11846): shared test-fixture library slice + first consolidation exemplars (#7114)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-06 11:06:40 -04:00
Suchintan
f2f3399e67
test(SKY-11845): delete verified-redundant unit tests (zero coverage loss) (#7113)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-06 11:04:47 -04:00
Celal Zamanoğlu
8ab0db16d1
feat(SKY-11890): persist edit-class studio pane layout (#7112) 2026-07-06 17:38:26 +03:00
Shuchang Zheng
cef0be8d3b
fix(downloads): upload downloads under their final name — wait out the in-flight extension rename (SKY-11849) (#7111)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-06 07:27:09 -07:00
Celal Zamanoğlu
f78ad441fc
fix(SKY-11885/11887/11888): block header cosmetic polish (#7110) 2026-07-06 17:14:55 +03:00
Aaron Perez
cc485dfedb
security: bump litellm >=1.84.0 in integrations/langchain (#6911)
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 13:49:19 +00:00
Celal Zamanoğlu
ade173ea11
test(SKY-11812): unit-cover the copilot auto-open-Editor one-shot (#7109) 2026-07-06 15:00:24 +03:00
Celal Zamanoğlu
b38764df3b
fix(SKY-11832): remove Inspecting chip from studio screenshots view (#7108) 2026-07-06 14:51:14 +03:00
Celal Zamanoğlu
a050833653
fix(SKY-11886): stop URL sync from reverting focus on repeat block adds (#7107) 2026-07-06 14:34:42 +03:00
Cindy Li
23f94b93b7
fix(copilot): keep composer placeholder on one line in narrow panes (#7106) 2026-07-06 04:06:15 -04:00
Cindy Li
e82ad57df7
fix(SKY-11882): resolve run-detail workflow via run join so template runs don't 404 (#7105) 2026-07-06 03:58:19 -04:00
Celal Zamanoğlu
3672af57da
feat(SKY-11814): jump-to-start/end buttons for long workflows (#7104) 2026-07-06 10:21:56 +03:00
Celal Zamanoğlu
a77a3bbccb
feat(SKY-11813): block search with focus-and-jump in the studio editor (#7103) 2026-07-06 10:14:48 +03:00
LawyZheng
54bc868931
feat(captcha): element-tree placeholder for visible Cloudflare Turnstile frames (SKY-11616) (#7102)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-06 14:24:59 +08:00
Andrew Neilson
e594525c7e
SKY-11855: bind scout-proven held-out inputs into copilot build-test runs + producer-recorded required_input_unbound (#7101) 2026-07-05 23:23:07 -07:00
Andrew Neilson
b72f4be7e8
feat(SKY-11852): positive independent-evidence carriers for requested-output completion (#7100) 2026-07-05 20:53:12 -07:00
Andrew Neilson
10d2b44145
fix(SKY-11591): actuate the separated-spine output-contract violation instead of returning unchanged (#7099) 2026-07-05 20:53:09 -07:00
Andrew Neilson
2d94acf6a2
fix(SKY-11854): recycle a scouting-only avoidable output-field-confirmation ASK instead of refusing (#7098) 2026-07-05 20:16:19 -07:00
Cindy Li
121d323ec2
feat: hint that Copilot accepts pasted recorded steps from other agents (#7097) 2026-07-05 22:51:32 -04:00
Shuchang Zheng
a95bab83a3
fix(SKY-11711): stage pdf_fill smoke-test source PDFs inside the run download dir (#7093)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-05 08:58:32 -07:00
Andrew Neilson
69ae62db5b
Reconcile 3 download-path files with OSS main to stop the sync re-introducing a path-injection (#7092) 2026-07-05 08:45:14 -07:00
Andrew Neilson
d36556a6de
SKY-11724: bind recorded-outcome repair as a typed convergence constraint, not prose (#7091)
Co-authored-by: AronPerez <aperez0295@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:42:42 -07:00
Andrew Neilson
31c333af5d
feat(SKY-11842): positive independent-evidence binding for requested-output completion (#7090)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:40:19 -07:00
Andrew Neilson
1fc396e2c1
fix(SKY-11843): gate copilot repair-ceiling terminalization on an executed-run guard (#7087)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-04 23:40:32 -07:00
Andrew Neilson
2d99adc09a
fix(SKY-11722): carry hollow act-observe on-screen values into the typed build-test outcome (#7088)
Co-authored-by: AronPerez <aperez0295@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:18:42 -07:00
Andrew Neilson
973f67b0f8
SKY-11833: type judgment-boolean provenance through Copilot completion grading (#7086)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:52:59 -07:00
Shuchang Zheng
7f8f30241e
fix(SKY-11825): tolerate non-JSON extracted_information in task read path (#7085)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Co-authored-by: AronPerez <aperez0295@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:39:59 -07:00
Shuchang Zheng
a62dbe996f
fix(SKY-7996): release local Playwright driver for remote-CDP browser states (#7084)
Co-authored-by: AronPerez <aperez0295@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:21:37 -07:00
Andrew Neilson
ece73f51e4
SKY-11818: unify build-scout goal-completeness with the typed requested-output contract (#7081)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-04 10:21:43 -07:00
Andrew Neilson
085c718712
test: consolidate copilot unit tests (−1,346 lines, coverage-preserving) (#7073)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-04 01:35:09 -07:00
Suchintan
9842cb5327
chore: update qa evidence signed Linear flow (#7071)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-04 02:07:14 -04:00
Andrew Neilson
ac0bfa20b6
SKY-11591: author-time output contract for Copilot build-time repair (#7069)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-03 21:24:10 -07:00
Suchintan
778257a379
feat(SKY-11774): HTTP Request block fetches response values as secrets + passes secrets between blocks (#7054)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-03 21:51:37 -04:00
Aaron Perez
56fb4f88bb
test(SKY-11786): characterization tests for execute_step exception handling (#7052) 2026-07-03 20:30:02 -05:00
Aaron Perez
dcb5a24c66
Show agent-scoped analytics snapshot above the runs list (#7051) 2026-07-03 20:12:24 -05:00
Celal Zamanoğlu
86e31de917
fix(SKY-11826): gate the legacy Visual/Code toggle to /edit and anchor it under the header (#7065)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-04 03:54:12 +03:00
Aaron Perez
babe19b186
SKY-11552: Show conditional and loop prompts in settings (#7050) 2026-07-03 19:50:33 -05:00
Celal Zamanoğlu
c7fead53ff
feat(SKY-11764): workflow studio launch polish — button grammar, pane UX, run-view defaults, resilience (#7056)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-04 03:32:55 +03:00
Andrew Neilson
b934c6165d
SKY-11721: enforce Copilot completion evidence provenance (#7055)
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-03 17:31:46 -07:00
Suchintan
234ebcba02
feat(SKY-11767): rotate login credentials across a pool per workflow run (#7053)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-03 20:08:26 -04:00
Suchintan
b8e789d665
Add ZIP support to FileParserBlock [SKY-11711] (#7049)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-03 19:44:13 -04:00
Abhay Singh
6e788520d7
[frontend] Fix run/block duration display dropping days past 24h (#7058) 2026-07-03 21:24:20 +00:00
Celal Zamanoğlu
7feeefe552
fix(SKY-11679): keep CDP-mode Record Browser alive across the transport swap (#7048)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-03 16:48:40 +03:00
Celal Zamanoğlu
7b4dd9ada8
feat(SKY-11755): studio pane drag-reorder, resizable widths, greedy browser layout (SKY-11756, SKY-11760) (#7047) 2026-07-03 16:31:05 +03:00
Celal Zamanoğlu
6bb409814a
feat(SKY-11758): timeline pane rename, cold-entry layout mappings, append-only continuity (SKY-11763) (#7046)
Some checks failed
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Auto Create GitHub Release on Version Change / check-version-change (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / check-version-change (push) Has been cancelled
Auto Create GitHub Release on Version Change / create-release (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / run-ci (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / build-sdk (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / publish-sdk (push) Has been cancelled
2026-07-03 15:30:20 +03:00
Celal Zamanoğlu
34d3196c64
feat(SKY-11758): slim the studio Run pane into a timeline + run-data pane (#7045) 2026-07-03 14:52:57 +03:00
Celal Zamanoğlu
c1c2f65580
feat(SKY-11758): studio Browser pane absorbs run visuals + single pane header (SKY-11762) (#7044) 2026-07-03 14:26:29 +03:00
Celal Zamanoğlu
4439cabdbd
fix(SKY-11759): studio editor pane — recenter on pane layout changes + start-anchored initial viewport (SKY-11761) (#7043) 2026-07-03 13:45:03 +03:00
LawyZheng
36f0d47a77
SKY-11640: avoid unsafe fallback for blocked anchor clicks (#7042) 2026-07-03 16:53:12 +08:00
pedrohsdb
6d63c5508e
feat(SKY-11729): migrate self-healing enablement from PostHog to a workflow setting (#7041)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-02 21:26:20 -07:00
Andrew Neilson
852040d107
SKY-11724 Ground recorded outcome repair attempts (#7040)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-02 20:59:14 -07:00
Suchintan
db3c87b650
[codex] Tolerate missing local LLM setup (#6947) 2026-07-03 03:34:36 +00:00
Aaron Perez
e79393b600
SKY-8927 Block format-string sandbox escape in code blocks (#7039) 2026-07-02 21:35:24 -05:00
Aaron Perez
38d160c18a
security: bump joserfc >=1.6.8 to fix CVE-2026-49852 (HMAC auth bypass) (#7038) 2026-07-02 21:09:26 -05:00
Marc Kelechava
0401d0960b
SKY-11526 Collapse autocomplete fanout (#7037) 2026-07-02 18:54:36 -07:00
Cindy Li
d10191f6ab
Browser recording front-end: live draft panel replaces editor, clean screenshots, new step kinds (#7036) 2026-07-02 21:16:07 -04:00
Aaron Perez
bb9c183fb3
refactor(SKY-11703): remove the AgentDB *args facade entirely (#7033) 2026-07-02 19:35:17 -05:00
Suchintan
03e23fe9ba
feat(SKY-11051): back Save & Reuse Session with auto-managed browser profiles (#7032)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-02 19:35:07 -04:00
Andrew Neilson
1c5ec09f9a
fix(SKY-11722): scout hollow act-observe outcome recording (#7031)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-02 16:12:58 -07:00
Celal Zamanoğlu
d735f172be
feat(SKY-11487): advanced full-screen YAML editing mode in workflow editor (#7030)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-03 01:46:34 +03:00
Suchintan
720d47d754
SKY-11678 Add local credential vault persistence to OSS Docker (#7003)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 21:24:17 +00:00
Marc Kelechava
ef22a35461
SKY-11570 log shadow-match disagreement details (#7027) 2026-07-02 14:02:29 -07:00
Aaron Perez
1fea7f8abc
SKY-11702 Migrate workflow params AgentDB callers (#7026) 2026-07-02 16:12:03 -04:00
Aaron Perez
c49ab35dab
feat(SKY-11075): recent-activity history view on workflow build canvas (#7025) 2026-07-02 15:42:59 -04:00
Aaron Perez
3536333904
SKY-11602 Surface run output signals in new UI (#7024) 2026-07-02 15:32:16 -04:00
Aaron Perez
e60b983cfe
fix: keep run view escapable from recording (#7021) 2026-07-02 15:20:00 -04:00
Marc Kelechava
226796b113
feat(evals): MCP eval scaffold + seed datasets (connector E1) (#7023) 2026-07-02 11:54:15 -07:00
Aaron Perez
53474e4ce7
Fix default open Studio Copilot (#7022) 2026-07-02 14:50:30 -04:00
Aaron Perez
6fcbbda66e
SKY-11701: Migrate scripts and schedules AgentDB callers (#7020) 2026-07-02 14:47:29 -04:00
Marc Kelechava
42edab6de3
feat(mcp): MCP_CODE_ONLY_MODE org flag + code-only default middleware (connector F1) (#7019) 2026-07-02 11:34:13 -07:00
Trevor Cheung
ae61830ec5
feat(copilot): emit workflow_run_id on block-running tool-result SSE frames (#7018) 2026-07-02 14:29:40 -04:00
Marc Kelechava
56dc1c51de
feat(mcp): code-block lint + synthesize tools (Copilot connector, M4) (#7017) 2026-07-02 11:08:39 -07:00
Aaron Perez
b111a8b95d
chore(SKY-11691): relocate artifact-storage tests out of the shipped skyvern package (#7016) 2026-07-02 13:55:57 -04:00
pedrohsdb
81ab993975
fix(SKY-11675): heal to end-of-block and verify heals with action history (#7015) 2026-07-02 10:47:06 -07:00
pedrohsdb
07618ceaf9
fix(SKY-11295): verify wrapped mini-goals against the mini goal (#7014) 2026-07-02 08:48:18 -07:00
Celal Zamanoğlu
8a118cadfe
feat(SKY-11664): studio editor pane — build-mode canvas at pane widths (#7013)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-02 17:34:52 +03:00
Celal Zamanoğlu
6ebf0c6f24
Rename /workflows URLs to /agents (backwards-compatible) [SKY-11668] (#7011) 2026-07-02 16:25:10 +03:00
Celal Zamanoğlu
d1cee63485
feat(SKY-11666): studio Run pane — Browser-pane interplay, run concurrency UX, output parity (#7010) 2026-07-02 15:55:24 +03:00
Celal Zamanoğlu
6b3d91e6b6
feat(SKY-11667): studio first-run experience — progressive disclosure (#7009) 2026-07-02 15:42:52 +03:00
Celal Zamanoğlu
cddad3a3f0
feat(SKY-11665): studio Browser pane — lock take-control while a block run executes (#7008) 2026-07-02 15:36:05 +03:00
Celal Zamanoğlu
cd088a4ce0
feat(SKY-11662, SKY-11663): studio spine + side-by-side panes (?panes= URL state), Copilot as a peer pane (#7007) 2026-07-02 15:17:21 +03:00
LawyZheng
e94e2c0172
fix: tolerate networkidle readiness failures (SKY-11613) (#7006) 2026-07-02 17:53:33 +08:00
Cindy Li
8a8af0d4af
perf(record): event-driven message pump instead of 1ms busy-poll drain (#7005) 2026-07-02 05:14:31 -04:00
Cindy Li
cfb3922705
perf(record): single exfiltration transport + None-tolerant dedup (#7004) 2026-07-02 03:29:03 -04:00
Cindy Li
3b6cba4f08
fix(record): start a fresh interpretation session per recording (#7002)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-02 00:39:40 -04:00
Suchintan
830674adc9
Avoid inherited blank page workflow URLs (#7001)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-02 00:36:11 -04:00
Andrew Neilson
e164e26164
fix(SKY-11638): persist Copilot review proof outputs (#7000) 2026-07-01 21:00:11 -07:00
Cindy Li
4e40b037cd
fix(record): stop NaN coordinates from killing the live-draft channel (#6999) 2026-07-01 23:20:57 -04:00
Marc Kelechava
fe84736f31
feat(mcp): enforce code-only mode on workflow persist path (connector, M4) (#6998) 2026-07-01 20:08:10 -07:00
Marc Kelechava
5950e00c10
feat(mcp): code-only mode for block validation (Copilot connector, M4) (#6997) 2026-07-01 20:00:44 -07:00
Andrew Neilson
fb6ff274ae
SKY-11642 repair read-only extraction verification (#6996) 2026-07-01 19:36:38 -07:00
Cindy Li
e52e90fd79
perf(record): emit interpretation deltas instead of full snapshots (#6995) 2026-07-01 22:17:35 -04:00
Andrew Neilson
86ae763d0e
SKY-11545: Fail closed requested output grounding (#6994) 2026-07-01 18:51:18 -07:00
Andrew Neilson
83edbca038
SKY-11630: Repair missing output dependency handling (#6993) 2026-07-01 18:33:30 -07:00
Aaron Perez
542dcc5dd4
style: align block picker padding (SKY-11595) (#6991) 2026-07-01 20:24:56 -04:00
Aaron Perez
b3fb369d89
feat(SKY-11554): CodeBlock runner action + post-action screenshot broker (#6990) 2026-07-01 20:06:14 -04:00
Aaron Perez
53d3955d06
fix: open diagnostics on selected code step (#6989) 2026-07-01 20:00:59 -04:00
Aaron Perez
a3724498b5
fix: clarify applied Copilot changes label (#6988) 2026-07-01 19:57:26 -04:00
Aaron Perez
0ca97ff587
SKY-11457 Clarify Copilot queued send action (#6987) 2026-07-01 19:44:47 -04:00
Aaron Perez
744e0b3d66
feat(SKY-11461): add Studio browser activity indicator (#6986) 2026-07-01 19:43:59 -04:00
Aaron Perez
f4efa23a5d
SKY-11607: Make screenshots easier to find in run UI (#6985) 2026-07-01 19:42:43 -04:00
Shuchang Zheng
dd88233151
Update changelog — Week of June 30, 2026 (#6984)
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: AronPerez <aperez0295@gmail.com>
2026-07-01 16:31:01 -07:00
Celal Zamanoğlu
fbfe3abffd
feat(SKY-11649): studio editor uses build mode (inline collapsible blocks) (#6982)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-02 01:58:54 +03:00
Andrew Neilson
cae1885c3a
SKY-11582: Stop repair loops on ungradeable requested-output checks (#6981) 2026-07-01 15:40:21 -07:00
Cindy Li
4f752a0677
perf(record): faster live-draft enrichment (Gemini Flash Lite) + snappier debounce (#6973)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-01 17:27:01 -04:00
pedrohsdb
6ea5783a3e
feat(SKY-11276): bounded code-block self-heal floor (#6971)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-01 14:00:39 -07:00
pedrohsdb
7c643b6a80
fix: run_alembic_check.sh must use the repo venv's alembic, not a global one (#6970) 2026-07-01 13:36:49 -07:00
Aaron Perez
ebb6f8f43c
feat: persist workflow studio block selection in URL (#6969) 2026-07-01 16:10:21 -04:00
Shuchang Zheng
93151f62a6
feat(SKY-11348): gate 720p browser recording behind PostHog flag (#6968) 2026-07-01 12:24:49 -07:00
Aaron Perez
798689d76d
fix(SKY-11608): restore syntax highlighting on large webhook payloads (#6967) 2026-07-01 12:19:50 -05:00
Aaron Perez
5808d8dcf3
SKY-11550: Hide empty extra HTTP headers (#6966) 2026-07-01 12:13:34 -05:00
Suchintan
5155891bfc
fix(SKY-11634): move ignore system prompt into advanced settings (#6964)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-01 10:06:03 -04:00
Suchintan
17cbbefb68
SKY-6452: Explain invalid JSON in workflow editor (#6963)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-01 09:47:30 -04:00
Suchintan
4ee1e13365
fix: enforce LLM block schema outputs (#6965)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-01 09:27:10 -04:00
Shuchang Zheng
53ad490ff8
feat: dispatch copilot block runs to the UI worker (#6962) 2026-07-01 05:11:35 -07:00
LawyZheng
527e9d2333
SKY-10620: Route data-only validation without page evidence (#6960)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-01 17:39:00 +08:00
LawyZheng
86c4a5e80c
Add browser runtime observability label (#6959) 2026-07-01 17:18:00 +08:00
Andrew Neilson
5fafb3bbcc
SKY-11611 credit corroborated requested-output abstentions (#6957) 2026-07-01 00:10:58 -07:00
Aaron Perez
4d1c0f7d3a
feat(SKY-11622): show block type in Studio block detail header (#6956)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
2026-07-01 01:07:08 -05:00
Suchintan
7739f4315e
docs: June 2026 monthly changelog email (#6955)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-01 01:46:44 -04:00
Suchintan
50f4692c76
docs: reformat legacy changelog entries to the Week-of format (#6954)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-07-01 01:45:57 -04:00
Suchintan
203ff03fdf
Improve authenticator setup feedback (#6953)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-07-01 00:35:50 -04:00
Aaron Perez
af4ed12684
SKY-11391: Dedupe run-tab action detail panel (remove ACTIONS(N) + Selected action; bind tabs to child) (#6952) 2026-06-30 23:16:58 -05:00
Aaron Perez
7d4304907c
SKY-11396: dedupe run-tab center card header label (#6950) 2026-06-30 22:59:52 -05:00
Cindy Li
0bd0da7d7c
fix: dedupe re-captured live browser-recording steps (#6951) 2026-06-30 23:41:19 -04:00
Aaron Perez
6b5787753a
feat(SKY-11392): action-detail card heading redesign (two-tier) (#6948) 2026-06-30 22:25:16 -05:00
Aaron Perez
8914ba161c
SKY-11398: consolidate live-run Code surfaces to one source of truth (#6945) 2026-06-30 22:23:26 -05:00
Aaron Perez
8a39e196dc
fix(SKY-11566): recover from active-session token failures on workflow loads (#6946) 2026-06-30 22:03:15 -05:00
Marc Kelechava
a2dda9c8b5
fix(mcp): accept trailing-slash resource in MCP OAuth authorize (unblocks Codex CLI) (#6944)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-06-30 19:55:20 -07:00
Shuchang Zheng
17d4201376
feat: execute workflow runs against the version stamped at creation (#6942) 2026-06-30 19:35:18 -07:00
Andrew Neilson
9266ba3539
SKY-11545: Fail closed author-time output gates (#6941) 2026-06-30 18:54:45 -07:00
Andrew Neilson
1de08f3eeb
SKY-11545: Add recorded outcome spine (#6939)
Co-authored-by: Aaron Perez <aperez0295@gmail.com>
2026-06-30 16:41:46 -07:00
Shuchang Zheng
ab5d58a6ed
feat(SKY-11555): auto-provision a browser session for CodeBlock runs (#6938) 2026-06-30 16:20:39 -07:00
pedrohsdb
bb3471a1c2
feat(SKY-11580): diagnose turns produce a useful diagnosis instead of dead-ending (#6937) 2026-06-30 15:48:48 -07:00
Suchintan
87dfc5a6d7
[codex] Add custom LLM configuration (#6936)
Some checks failed
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Auto Create GitHub Release on Version Change / check-version-change (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / check-version-change (push) Has been cancelled
Build Skyvern TS SDK and publish to npm / check-version-change (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / publish-sdk (push) Has been cancelled
Auto Create GitHub Release on Version Change / create-release (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / run-ci (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / build-sdk (push) Has been cancelled
Build Skyvern TS SDK and publish to npm / build-and-publish-sdk (push) Has been cancelled
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-06-30 17:45:45 -04:00
988 changed files with 116962 additions and 28218 deletions

View file

@ -4,6 +4,9 @@
**/traces
**/inputs
**/har
**/downloads
**/browser_sessions
**/credential_vault
**/.git
**/.github
**/.mypy_cache

View file

@ -139,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

2
.gitignore vendored
View file

@ -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/*

View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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"),
)

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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.

View file

@ -2683,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",
@ -3645,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",
@ -4018,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": [
@ -5559,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",
@ -8669,6 +8975,33 @@
],
"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",
@ -9013,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": {
@ -9964,6 +10312,7 @@
],
"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"
@ -10170,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",
@ -10230,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",
@ -10285,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": [
@ -10405,6 +10804,7 @@
"CredentialVaultType": {
"type": "string",
"enum": [
"skyvern",
"bitwarden",
"azure_vault",
"gcp",
@ -10566,6 +10966,214 @@
"title": "CreditCardCredentialResponse",
"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": {
"ok": {
@ -12295,7 +12903,8 @@
"excel",
"pdf",
"image",
"docx"
"docx",
"zip"
],
"title": "FileType"
},
@ -14106,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": [
@ -14306,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": [
{
@ -23471,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": [
{
@ -23585,6 +24227,11 @@
"title": "Adaptive Caching",
"default": false
},
"enable_self_healing": {
"type": "boolean",
"title": "Enable Self Healing",
"default": false
},
"code_version": {
"anyOf": [
{
@ -23667,6 +24314,11 @@
],
"title": "Edited By"
},
"copilot_authored": {
"type": "boolean",
"title": "Copilot Authored",
"default": false
},
"created_at": {
"type": "string",
"format": "date-time",
@ -23784,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": [
{
@ -23909,6 +24566,17 @@
"title": "Adaptive Caching",
"default": false
},
"enable_self_healing": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Enable Self Healing"
},
"code_version": {
"anyOf": [
{
@ -25396,6 +26064,17 @@
],
"title": "Body"
},
"prompt": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Prompt"
},
"instructions": {
"anyOf": [
{

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

View file

@ -13,6 +13,7 @@ keywords:
<div className="cl-page">
<div className="cl-sidebar">
<p className="cl-sidebar-title">TIMELINE</p>
<a href="#week-jun-30-2026" className="cl-sidebar-link">Week of Jun 30, 2026</a>
<a href="#week-jun-22-2026" className="cl-sidebar-link">Week of Jun 22, 2026</a>
<a href="#week-jun-15-2026" className="cl-sidebar-link">Week of Jun 15, 2026</a>
<a href="#week-jun-8-2026" className="cl-sidebar-link">Week of Jun 8, 2026</a>
@ -22,29 +23,44 @@ keywords:
<a href="#week-may-11-2026" className="cl-sidebar-link">Week of May 11, 2026</a>
<a href="#week-may-4-2026" className="cl-sidebar-link">Week of May 4, 2026</a>
<a href="#week-apr-27-2026" className="cl-sidebar-link">Week of Apr 27, 2026</a>
<a href="#v1-0-24" className="cl-sidebar-link">v1.0.24 — Apr 21, 2026</a>
<a href="#v1-0-23" className="cl-sidebar-link">v1.0.23 — Apr 14, 2026</a>
<a href="#v1-0-22" className="cl-sidebar-link">v1.0.22 — Feb 26, 2026</a>
<a href="#v1-0-21" className="cl-sidebar-link">v1.0.21 — Feb 24, 2026</a>
<a href="#v1-0-20" className="cl-sidebar-link">v1.0.20 — Feb 21, 2026</a>
<a href="#v1-0-19" className="cl-sidebar-link">v1.0.19 — Feb 20, 2026</a>
<a href="#v1-0-18" className="cl-sidebar-link">v1.0.18 — Feb 19, 2026</a>
<a href="#v1-0-17" className="cl-sidebar-link">v1.0.17 — Feb 19, 2026</a>
<a href="#v1-0-16" className="cl-sidebar-link">v1.0.16 — Feb 19, 2026</a>
<a href="#v1-0-15" className="cl-sidebar-link">v1.0.15 — Feb 17, 2026</a>
<a href="#v1-0-14" className="cl-sidebar-link">v1.0.14 — Feb 17, 2026</a>
<a href="#v1-0-13" className="cl-sidebar-link">v1.0.13 — Feb 10, 2026</a>
<a href="#v1-0-12" className="cl-sidebar-link">v1.0.12 — Feb 3, 2026</a>
<a href="#v1-0-11" className="cl-sidebar-link">v1.0.11 — Jan 29, 2026</a>
<a href="#v1-0-10" className="cl-sidebar-link">v1.0.10 — Jan 22, 2026</a>
<a href="#v1-0-9" className="cl-sidebar-link">v1.0.9 — Jan 20, 2026</a>
<a href="#v1-0-8" className="cl-sidebar-link">v1.0.8 — Jan 9, 2026</a>
<a href="#week-apr-20-2026" className="cl-sidebar-link">Week of Apr 20, 2026</a>
<a href="#week-apr-13-2026" className="cl-sidebar-link">Week of Apr 13, 2026</a>
<a href="#week-feb-23-2026" className="cl-sidebar-link">Week of Feb 23, 2026</a>
<a href="#week-feb-16-2026" className="cl-sidebar-link">Week of Feb 16, 2026</a>
<a href="#week-feb-9-2026" className="cl-sidebar-link">Week of Feb 9, 2026</a>
<a href="#week-feb-2-2026" className="cl-sidebar-link">Week of Feb 2, 2026</a>
<a href="#week-jan-26-2026" className="cl-sidebar-link">Week of Jan 26, 2026</a>
<a href="#week-jan-19-2026" className="cl-sidebar-link">Week of Jan 19, 2026</a>
<a href="#week-jan-5-2026" className="cl-sidebar-link">Week of Jan 5, 2026</a>
</div>
<div className="cl-content">
<h1 className="cl-title">Changelog</h1>
<p className="cl-subtitle">New features, improvements, and fixes in Skyvern</p>
<a id="week-jun-30-2026"></a>
<Update label="Week of June 30, 2026" description="Workflow Studio UX improvements, faster enrichment, code-block self-heal, extraction accuracy, bug fixes">
## 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))
</Update>
<a id="week-jun-22-2026"></a>
<Update label="Week of June 22, 2026" description="Workflow Studio preview, new models, label management, Gmail OTP, Copilot improvements, analytics filters, bug fixes">
@ -419,8 +435,8 @@ keywords:
</Update>
<a id="v1-0-24"></a>
<Update label="v1.0.24 — April 21, 2026" description="Copilot reliability, run recordings, 2FA improvements, and more">
<a id="week-apr-20-2026"></a>
<Update label="Week of April 20, 2026" description="Copilot reliability, run recordings, 2FA improvements, and more">
## Improvements
@ -437,8 +453,8 @@ keywords:
</Update>
<a id="v1-0-23"></a>
<Update label="v1.0.23 — April 14, 2026" description="Saudi Arabia proxy, workflow error mapping, smarter caching, MCP OAuth, AI output summaries, and reliability fixes">
<a id="week-apr-13-2026"></a>
<Update label="Week of April 13, 2026" description="Saudi Arabia proxy, workflow error mapping, smarter caching, MCP OAuth, AI output summaries, and reliability fixes">
## New features
@ -482,38 +498,35 @@ keywords:
</Update>
<a id="v1-0-22"></a>
<Update label="v1.0.22 — February 26, 2026" description="Adaptive caching, Workflow Trigger block, CLI signup, and more">
<a id="week-feb-23-2026"></a>
<Update label="Week of February 23, 2026" description="Adaptive caching, Workflow Trigger block, CLI signup, reserved parameters, new LLM models">
## 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
@ -525,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))
</Update>
<a id="v1-0-21"></a>
<Update label="v1.0.21 — February 24, 2026" description="Reserved parameters, data_schema for loops, new LLM models">
## 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))
</Update>
<a id="v1-0-20"></a>
<Update label="v1.0.20 — February 21, 2026" description="Browser profile testing UI and 2FA verification flow">
<a id="week-feb-16-2026"></a>
<Update label="Week of February 16, 2026" description="Unified Browser Task block, browser profile testing, 2FA detection, Skills package, Claude Opus 4.6, MCP block discovery">
## 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))
</Update>
<a id="v1-0-19"></a>
<Update label="v1.0.19 — February 20, 2026" description="Admin impersonation controls and configuration improvements">
## 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))
</Update>
<a id="v1-0-18"></a>
<Update label="v1.0.18 — February 19, 2026" description="Browser profile testing and saved-profile workflows">
## 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))
</Update>
<a id="v1-0-17"></a>
<Update label="v1.0.17 — February 19, 2026" description="Skills package and cached script management">
## 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))
</Update>
<a id="v1-0-16"></a>
<Update label="v1.0.16 — February 19, 2026" description="2FA detection, full CLI parity, Claude Opus 4.6 CUA">
## 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))
</Update>
<a id="v1-0-15"></a>
<Update label="v1.0.15 — February 17, 2026" description="Claude Opus 4.6 model support">
## 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))
</Update>
<a id="v1-0-14"></a>
<Update label="v1.0.14 — February 17, 2026" description="Unified Browser Task block, MCP block discovery, and credential editing">
## 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))
</Update>
<a id="v1-0-13"></a>
<Update label="v1.0.13 — February 10, 2026" description="Cached scripts for control-flow blocks, OTEL, and workflow editor polish">
<a id="week-feb-9-2026"></a>
<Update label="Week of February 9, 2026" description="Cached scripts for control-flow blocks, OTEL, and workflow editor polish">
## New features
@ -699,8 +614,8 @@ keywords:
</Update>
<a id="v1-0-12"></a>
<Update label="v1.0.12 — February 3, 2026" description="Task V2 termination, ForLoop caching, and cleaner conditional debugging">
<a id="week-feb-2-2026"></a>
<Update label="Week of February 2, 2026" description="Task V2 termination, ForLoop caching, and cleaner conditional debugging">
## New features
@ -732,8 +647,8 @@ keywords:
</Update>
<a id="v1-0-11"></a>
<Update label="v1.0.11 — January 29, 2026" description="Workflow Copilot GA, Browser Sessions v2 frontend, and TOTP improvements">
<a id="week-jan-26-2026"></a>
<Update label="Week of January 26, 2026" description="Workflow Copilot GA, Browser Sessions v2 frontend, and TOTP improvements">
## New features
@ -763,26 +678,32 @@ keywords:
</Update>
<a id="v1-0-10"></a>
<Update label="v1.0.10 — January 22, 2026" description="Browser Sessions v2 backend, general text CAPTCHA, and TOTP webhook retries">
<a id="week-jan-19-2026"></a>
<Update label="Week of January 19, 2026" description="Browser Sessions v2, Print PDF block, Copilot streaming, general text CAPTCHA, HTTP file downloads">
## 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
@ -792,39 +713,8 @@ keywords:
</Update>
<a id="v1-0-9"></a>
<Update label="v1.0.9 — January 20, 2026" description="Print PDF block, Workflow Copilot streaming, and persistent session billing">
## 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))
</Update>
<a id="v1-0-8"></a>
<Update label="v1.0.8 — January 9, 2026" description="Workflow Copilot v1, Azure secret storage, and per-action billing">
<a id="week-jan-5-2026"></a>
<Update label="Week of January 5, 2026" description="Workflow Copilot v1, Azure secret storage, and per-action billing">
## New features

View file

@ -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. |
---

View file

@ -0,0 +1,306 @@
# Workflow Build — Recent Activity History (SKY-11075)
Replace the bottom-of-canvas **dot/carousel indicator** on `/workflows/:id/build` with a
real recent-activity history view that's easy to scan and lets you jump directly to a
specific recent activity.
Status: **Final — recent activity is a compact run selector locked to the right
run-detail panel; the A/B/C style and placement preview toggles were removed. See
the Round 7 section below.**
---
## 1. Frame
- **Page:** Workflow editor, *build* mode — `/workflows/:id/build` (and the run-loaded
variant `/workflows/:wpid/:workflowRunId/:blockLabel/build`). The history surface only
renders in **debug/browser mode** (`showBrowser`), bottom-center over the canvas+code
pane. Redesign of an existing affordance.
- **Job to be done:** While iterating on a workflow you fire blocks/the workflow many times
in one debug session. Today those runs collapse into a row of identical ~16px dots whose
only metadata is a hover tooltip. You can't tell at a glance *what* ran, *whether it
passed*, *how long it took*, or *which mode* (agent vs cached code) — you must hover each
dot one by one. The job: make each recent run legible at a glance and jump straight to it.
- **Deciding constraint — what counts as "recent activity":** **Debug-session runs**
(confirmed with requester). Each entry = one block/workflow run triggered in the current
debug session, sourced from `GET /debug-session/{id}/runs` (`useDebugSessionRunsQuery`).
No new backend. Deliberately distinct from (a) the global **Runs** page and (b) the
right-side **run-detail timeline** (`DebuggerRun`/`DebuggerRunTimeline`) which shows the
*inside* of one selected run. This surface is the *list of runs*; selecting one drives the
existing detail rail — no overlapping history surfaces.
- **Open sub-question (requester):** is a dock/strip the best surface, or do we want a
"proper timeline"? → resolved via the concepts below; requester to pick direction.
- **Platform:** web.
- **North star references:** Databricks pipeline run-selector + event log; Vercel Activity feed.
### Data available per entry (`DebugSessionRun`) — today all hidden behind one dot
`block_label`, `status` (running/completed/failed/terminated/timed_out/canceled/queued/
skipped), `created_at` / `queued_at` / `started_at` / `finished_at` (→ duration + "x ago"),
`failure_reason`, `run_with` (agent | code), `code_gen`, `ai_fallback`, `workflow_run_id`,
`workflow_permanent_id`, `script_run_id`. Selecting → `navigate(/workflows/{wpid}/{workflow_run_id}/{block_label}/build)`.
---
## 2. DS inventory (read from code — Figma DS is thin by design)
| Need | Reuse (exists in repo) |
|---|---|
| Status glyph | `StatusDot` pattern (WorkflowRunTimelineBlockItem): `CheckCircledIcon text-success`, `CrossCircledIcon text-destructive`, `ReloadIcon animate-spin text-sky-400`, neutral `rounded-full bg-slate-600` |
| Block-type glyph | `WorkflowBlockIcon` (per `WorkflowBlockType`) |
| Type / mode pills | inline pill convention `rounded bg-slate-700/70 px-1.5 py-0.5 text-[10px] font-medium` |
| Relative time / duration | `formatMs(Date.now()-created).ago`, `formatDuration(toDuration(ms))`, `tabular-nums` |
| Tooltip | `Tip` (`@/components/Tip`) |
| Scroll container | `ScrollArea` (`@/components/ui/scroll-area`) |
| Dropdown / flyout | `Popover` (`@/components/ui/popover`) |
| Expand / collapse | `Collapsible` (`@/components/ui/collapsible`) |
| Buttons / badges | `Button`, `Badge` |
| Surface tokens | `bg-neutral-50 dark:bg-background`, `border-neutral-200 dark:border-neutral-800`, focus `focus-visible:ring-1 focus-visible:ring-white/40` |
Conventions: build-mode rails skew dark (`slate-*`); pills `text-[10px]`, `tabular-nums` for
numbers; every interactive row is a `<button>` with a visible focus ring.
## 3. Gap ledger (DS lacks → candidate Step-5 tickets)
- No dedicated **Status badge** component — status color/icon mapping is re-derived inline in
several places (`StatusDot`, run cards). Candidate: extract a shared `RunStatusBadge`.
- No **horizontal "activity strip"/run-history rail** primitive.
- (Append as concepts/implementation pressure the library.)
---
## 4. Inspiration (Mobbin — web)
1. **Databricks — pipeline "Update details"** — editor canvas with a **Run: {timestamp}
[status]** selector top-left, a detail panel (Update ID, *Status, Duration, Start time,
Run time, Completion date*), and a timestamped **Event log** below. Closest analog: a
build canvas whose run history is a compact selector + per-run metadata.
[link](https://mobbin.com/screens/2a53f9eb-b8e1-4958-afb7-f441acbdf414). a11y: the
selector must be a real listbox/menu button, not a click-only div.
2. **Vercel — Activity** — scannable vertical feed: each row = entity icon + plain-language
description + **relative time** ("42m"), grouped by date, left filter rail. Gold standard
for "legible at a glance."
[link](https://mobbin.com/screens/2f3056eb-a31a-4b3e-ae4b-1bcc1352117f). a11y: relative
time needs a `title`/`<time datetime>` with the absolute timestamp.
3. **WorkOS — Events** — master-detail: list (event + date/time) on the left, metadata on the
right. [link](https://mobbin.com/screens/a5b50327-60e0-4075-9cfd-7f383d18265c).
4. **Okta — System Log** / **Stripe — Events** / **Cloudflare — Observability** — dense log
tables (timestamp · type · message), some with a count-over-time bar.
[Okta](https://mobbin.com/screens/70f42b3f-1823-4cb2-872d-346ad3eab8dc) ·
[Stripe](https://mobbin.com/screens/5423e185-8374-4b6c-a4a0-2d3ae9adc1d0) ·
[Cloudflare](https://mobbin.com/screens/db64bfde-eac6-4786-8c29-62b98eb1a149). a11y:
color-coded status must pair with an icon/text, not color alone.
---
## 5. Concepts
All three keep the data scope (debug-session runs), keep the existing bottom-center anchor
over the canvas+code pane, and preserve the existing select→navigate behavior. They differ in
*how the history is surfaced*.
### Concept A — "Activity Strip" (in-place horizontal chips)
- **Thesis:** smallest change. Swap the row of bare dots for a row of legible **chips** in the
same pill, scrolling horizontally. Each chip: status glyph + block icon + block label +
"x ago"; duration/mode in tooltip. Click → navigate.
- **Lineage:** Databricks run-selector (canvas-anchored) + Vercel chip legibility.
- **DS:** `StatusDot`, `WorkflowBlockIcon`, pills, `ScrollArea`, `formatMs`. Same wrapper/position.
- **Tradeoffs:** + tiny diff, preserves muscle memory, fast. horizontal scroll is less
scannable than vertical; limited metadata per chip; long lists hide off-screen.
### Concept B — "Activity History panel" (collapsed timeline bar → expanding vertical list) — RECOMMENDED
- **Thesis:** the dock's **collapsed** state is a slim **status-segment bar** — one thin
segment per run, colored by status, newest on the right, labeled "N runs · last ✓ 2m ago".
That's the at-a-glance, time-ordered "timeline." Click/hover **expands upward** into a
**vertical, scannable list**: one rich row per run = status glyph · block icon · block name ·
type pill · mode pill (Agent/Code) · duration · "x ago", with a one-line failure reason on
failures. Newest first. Row click → navigate (existing behavior); current run highlighted.
- **Lineage:** Vercel Activity feed (legible rows) + Okta count-over-time bar (collapsed
status density) + WorkOS master-detail (list feeds the existing right-side detail rail).
- **DS:** `Popover`/`Collapsible` + `ScrollArea`, the `WorkflowRunTimelineBlockItem` row
vocabulary, `StatusDot`, pills. New: a small **status-segment bar** (gap-ledger item).
- **Tradeoffs:** + most legible (vertical rows, full metadata — directly satisfies the AC),
+ collapsed footprint stays tiny so the canvas isn't crowded, + serves *both* the
"scannable history" and "proper timeline" instincts, + scales to long lists.
one extra primitive (segment bar); popover sizing over the canvas needs care.
### Concept C — "Run Selector + Segmented Timeline" (header selector + bottom bar)
- **Thesis:** Databricks-style. A **run selector** near the header ("Run: Login ✓ · 2m ago ▾")
opens the activity list, *and* a persistent thin **segmented timeline bar** sits at the
bottom (each run a proportional, status-colored segment; hover→tooltip, click→navigate).
- **Lineage:** Databricks run-selector + Okta status-density bar.
- **DS:** `Popover`, segment bar (new), `StatusDot` colors.
- **Tradeoffs:** + strongest "timeline" metaphor, scales well. most net-new UI and two
surfaces (header + bottom) risk fragmenting the history; largest build/QA; header is
already busy in build mode.
## 6. Recommendation & Decision
**Recommend Concept B.** It's the most faithful to the acceptance criteria — each entry
legible at a glance with real metadata, and one click to jump to a run — while keeping the
collapsed footprint as small as today's dot pill. Its collapsed status-segment bar answers the
requester's "proper timeline?" instinct without splitting the history across two surfaces
(Concept C's main risk), and it reuses the established run-row vocabulary so it looks native.
Decision (2026-06-15): requester chose to **build all three (A, B, C) behind a live variant
toggle** so they can compare directly in the PR preview, then collapse to one winner later.
Implementation ships a `RecentActivity` orchestrator that renders the variant selected by a
persisted (localStorage + `?activityVariant=` URL seed) toggle; default = B.
## 7. Implementation (shipped)
New module `skyvern-frontend/src/routes/workflows/debugger/recentActivity/`:
- `useRecentActivity.ts` — data hook (debug-session runs, sorted asc; block-type map;
`navigateToRun` preserving the old select→navigate target, now also resolving nested loop
blocks for the "block still exists" guard).
- `runActivity.ts` / `RunGlyphs.tsx` — shared status/duration/time helpers + status/block glyphs.
- `RecentActivitySegmentBar.tsx`, `RecentActivityList.tsx` — shared primitives.
- `RecentActivityStrip.tsx` (A), `RecentActivityPanel.tsx` (B), `RecentActivityTimeline.tsx`
(C), `RecentActivityVariantToggle.tsx`, `RecentActivity.tsx` (orchestrator).
- `src/store/RecentActivityVariantStore.ts` — variant store.
- `Workspace.tsx` now renders `<RecentActivity />`; `DebuggerBlockRuns.tsx` deleted.
Verification: `tsc --noEmit` ✓, `eslint --max-warnings 0` ✓, `vite build` ✓. Live visual
verification of the populated state needs a debug session with ≥1 run (backend + browser
session) — delegated to the PR preview by design (the whole point of the A/B/C toggle).
### Gap ledger outcome (→ Step 5 DS ticket)
- Shipped one-off **`RecentActivitySegmentBar`** (status-segment/timeline-bar primitive) — DS has none.
- Re-derived **run-status → icon/color** inline (`RunGlyphs` + `STATUS_PILL_TONE`/`SEGMENT_FILL`)
because there's no shared `RunStatusBadge`.
- Filed **SKY-11082** (team Skyvern AI) to extract both primitives into the DS once the winning
variant is chosen. Pure helpers covered by `runActivity.test.ts`.
---
# Round 2 — Placement exploration (2026-06-16)
The A/B/C work settled the *surface design*; this round explores *where on the build page*
the surface lives. Orthogonal axis: any style (A/B/C) can sit at any placement.
## Frame (placement)
The surface only renders in **debug/browser mode** (`showBrowser`), inside the vertical
`Splitter`. Candidate regions and their occupants:
- **Left pane** = code (left half) + infinite canvas (right half). Bottom-left corner holds the
FlowRenderer controls (FitView/Lock/GlobalCollapse); top is overlaid by `WorkflowHeader`
(`absolute left-6 top-8 h-20`) and header-anchored sub-panels (`top-[8.5rem]`).
- **Right pane** (`skyvern-split-right`) = live browser + footer (copilot/power/reload); far
right edge holds the run-detail **timeline rail** (`w-[5rem]`, expandable, `z-[15]`).
**Deciding constraint:** a placement must not block the live browser, the canvas controls, or
the run-detail timeline, and should not fight the header/sub-panels. So placements are
pointer-events-transparent floating bands (only the dock itself captures clicks).
## Inspiration (Mobbin — web, node-canvas editors)
- **WRITER — Blueprints**: execution log as a full-width **bottom** panel (node rows + status +
Trace + duration). [link](https://mobbin.com/screens/2368f17d-4fcc-4590-912d-08cb39245dab)
- **n8n — Executions**: run history as a **left side panel** (status + duration, auto-refresh),
entered via a top **Editor | Executions** tab.
[link](https://mobbin.com/screens/d3af4413-04f9-4534-8aa4-01916d2b4910)
- **StackAI — Version history / Run Details**: **right side panel** co-located with the run
(status, timestamps, duration, tokens).
[link](https://mobbin.com/screens/cffa32bb-fa1e-43fa-913b-27001b35f395) ·
[link](https://mobbin.com/screens/bb0174f4-60aa-4e30-ac5f-73679b160f38)
- **Databricks** (round 1): top **run-selector** + bottom **event log** in one editor.
## Placement concepts
- **P1 — Bottom dock (baseline, current):** floating bottom-center over the canvas/code pane.
Lineage: WRITER bottom log + StackAI bottom toolbar. + canvas stays clear, controls reachable.
easy to miss; far from where the eye starts.
- **P2 — Top rail (under header):** pinned top-center over the canvas/code pane, just below the
header. Lineage: n8n Editor|Executions top tab + Databricks top run-selector. + high
discoverability, reads top-down, near run controls. competes with header sub-panels when one
is open; popover opens downward over the canvas.
- **P3 — Browser-pane header (co-located with run viewing):** pinned to the top of the right
(live-browser) pane, so picking a recent run sits beside viewing that run + its detail
timeline. Lineage: StackAI right-side run history/details. + strongest pick→view coherence
(directly answers the ticket's "reconcile with run viewing"). eats a strip of browser space;
must clear the far-right timeline rail.
**Recommendation:** **P3 (browser-pane header)** for coherence — it puts "which run" right next
to "this run's browser + timeline," which is the actual debugging loop — with **P1** as the
low-risk default if we want the canvas untouched. P2 is the most discoverable but the most
crowded.
**Decision (2026-06-16):** per the established pattern, build all three behind a **placement
toggle** (orthogonal to the A/B/C style toggle; persisted via localStorage + `?activityPlacement=`
URL seed; default `bottom`) so they can be compared live in the PR preview.
### Gap-ledger addition
- No shared **floating-dock anchor** convention for build-canvas overlays (each overlay
re-implements `absolute … pointer-events-none [&>*]:pointer-events-auto`). Minor; noted on
[[SKY-11082]] rather than a new ticket.
## Round 2 decision — style converges to one "Activity Timeline" (2026-06-16)
Requester: "I like C the most, and B for its compression and all the events in one place."
**Merge B + C into a single default surface** and drop A (and the A/B/C style toggle):
**Activity Timeline** — compact two-row dock:
- Row 1: a **run selector** ("Run: ✓ Login · 2m ago ▾") + a compression summary ("N runs ·
last ✓ 2m ago"). The selector opens **B's consolidated, scannable event list**
(`RecentActivityList`) — all events in one place.
- Row 2: a **persistent segmented timeline bar** (C) — proportional, status-colored; hover→tip,
click a segment → jump to that run.
This keeps C's timeline metaphor + direct segment navigation, with B's compression (compact
collapsed form) and single consolidated list. Built behind the **placement toggle** (bottom /
top / right) so location can still be compared in the PR preview.
## Round 3 decision — placement locked to browser-pane header (2026-06-16)
Requester locked the **browser-pane header (right)** placement. Removed the placement toggle +
store and the bottom/top anchors; `RecentActivity` now mounts unconditionally at the top of the
live-browser pane (`popoverSide="bottom"`). Rendered visual proof (mock runs) below — produced
via a throwaway standalone preview page, not committed.
![Activity Timeline preview](./activity-timeline-preview.png)
## Round 4 — re-added the 3-way placement toggle (2026-06-16)
Reverted the round-3 lock: requester wants to compare bottom / top / right **by eye** in the
preview rather than commit blind. Restored `RecentActivityPlacementStore` +
`RecentActivityPlacementToggle` and the three Workspace anchors (default `bottom`,
`?activityPlacement=` URL seed). Decide, then re-lock.
![Placement comparison](./activity-timeline-placements.png)
## Round 5 — drop floating, integrate the run selector (2026-06-16)
Real screenshots showed every floating placement collides with canvas/browser content and
duplicates the existing right-side run-detail panel (`DebuggerRunTimeline`: Actions/Steps/Credits
+ block/action timeline) — the overlap the ticket warned about. So: **remove the floating dock,
segmented bar, and placement toggle**, and make recent-activity a compact **run selector** that
opens the consolidated list. Two integration points behind a small mode toggle (default `panel`,
`?activityMode=panel|toolbar`):
- **panel:** selector at the top of the right run-detail panel (`DebuggerRun`) — co-located with
viewing; picking a run drives the panel below.
- **toolbar:** selector as a dropdown in the editor header toolbar (`EditorActionToolbar`).
Removed: `RecentActivity`, `RecentActivityTimeline`, `RecentActivitySegmentBar`,
`RecentActivityPlacementToggle`, `RecentActivityPlacementStore`. Added: `RecentActivityRunSelector`,
`RecentActivityIntegrationStore`, `RecentActivityIntegrationToggle`.
## Round 6 — timeline strip below the browser (2026-06-16)
Panel/toolbar selectors still read as bolted-on. Requester idea: dock the timeline as a
horizontal strip **below the live browser session window** — the browser is what you're
watching, so a run history beneath it is contextual (WRITER bottom log / Databricks event-log
lineage). Added as a third integration option (`browser`) on the mode toggle:
`RecentActivityStrip` — a scannable horizontal row of run chips (oldest→newest, auto-scrolled to
newest, click→jump) + an "N runs ▾" button opening the consolidated list. Mounted between the
browser stream and its footer in both VNC and CDP panels. Compare via the Panel/Toolbar/Browser
toggle (`?activityMode=panel|toolbar|browser`).
## Round 7 — placement locked to the run-detail panel; preview toggle removed (final, 2026-06-29)
Compared the three integrations live in the PR preview and **locked `panel`**: the run
selector mounts at the top of the right run-detail panel (`DebuggerRun`), co-located with
viewing a run, which reconciles the history surface with run viewing (the ticket's open
question) without a second overlapping surface. Removed the exploration scaffolding —
`RecentActivityIntegrationStore` (+test), `RecentActivityIntegrationToggle`, and
`RecentActivityStrip` — plus the toolbar/browser wirings in `WorkflowHeader`/`Workspace`.
`RecentActivityRunSelector` mounts unconditionally (it self-hides until the debug session has
≥1 run) and its now-single-use `variant`/`popoverSide` props were dropped. Run rows in the
consolidated list are disabled while a workflow run is in progress (navigation is a no-op
then), covered by `RecentActivityList.test.tsx`. Dead exports `SEGMENT_FILL` and
`getRunTooltip` (orphaned when the segment bar / strip were cut) were removed.

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View file

@ -26,7 +26,7 @@ for (const c of creds) {
|-----------|------|----------|---------|-------------|
| `page` | `int` | No | `None` | Page number. |
| `page_size` | `int` | No | `None` | Results per page. |
| `vault_type` | `CredentialVaultType` | No | `None` | Filter credentials by vault type (e.g. `"custom"`, `"bitwarden"`, `"azure_vault"`). |
| `vault_type` | `CredentialVaultType` | No | `None` | Filter credentials by vault type (e.g. `"skyvern"`, `"custom"`, `"bitwarden"`, `"azure_vault"`). |
| `request_options` | `RequestOptions` | No | `None` | Per-request configuration (see below). |
### Returns `list[CredentialResponse]`

View file

@ -64,6 +64,16 @@ codex mcp get skyvern
If you previously configured Skyvern with an `x-api-key` header, run `codex mcp remove skyvern` first so Codex does not skip the OAuth handshake. You do not need to run `codex mcp login skyvern` after `add` — the OAuth flow runs inline. Use `codex mcp login skyvern` only to re-authenticate after a token expires or to request custom `--scopes`.
<Note>
If `codex mcp add` fails with `resource is not allowed for this authorization server`, your Codex version derived a trailing-slash `resource` from the URL. Pass the slashless resource explicitly:
```bash
codex mcp add skyvern --url https://api.skyvern.com/mcp/ --oauth-resource https://api.skyvern.com/mcp
```
Skyvern Cloud accepts both forms, so the flag is only needed on older self-hosted deployments.
</Note>
</Tab>
<Tab title="Cursor">
@ -130,18 +140,19 @@ claude mcp add-json skyvern '{"type":"http","url":"https://api.skyvern.com/mcp/"
3. When Claude Desktop opens the Skyvern installer preview, click **Install**.
4. In the **Configure Skyvern** dialog, paste your Skyvern API key.
5. Leave **Skyvern MCP URL** set to `https://api.skyvern.com/mcp/` unless you are connecting to a different deployment, then click **Save**.
6. In Claude, ask: "Create a Skyvern browser session". A working setup returns a Skyvern browser session id.
If double-clicking is blocked by your system, you can instead open **Claude Desktop > Settings > Extensions > Install Extension** and select the downloaded file manually.
The bundle defaults to `https://api.skyvern.com/mcp/` and removes the separate Node.js requirement.
**Linux, self-hosted, or advanced fallback (requires Node.js >= 20)**
<Note>
If Claude says Skyvern "needs authorization" or mentions OAuth, the API key is not configured. Open Claude Desktop **Settings -> Extensions -> Skyvern -> Configure**, paste your Skyvern API key, and save.
</Note>
Add this manual bridge to your Claude Desktop config file:
**Linux manual config (requires Node.js >= 20)**
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
On Linux, add this manual bridge to `~/.config/Claude/claude_desktop_config.json`:
```json
{
@ -159,7 +170,7 @@ Add this manual bridge to your Claude Desktop config file:
}
```
Use this fallback for Linux or any Claude Desktop setup where the one-click bundle is not available. It uses [mcp-remote](https://www.npmjs.com/package/mcp-remote) to bridge the remote MCP server to stdio and requires Node.js >= 20.
Use this Linux path when the one-click bundle is not available on your platform. It uses [mcp-remote](https://www.npmjs.com/package/mcp-remote) to bridge the remote MCP server to stdio and requires Node.js >= 20.
</Tab>
<Tab title="Cursor">

View file

@ -7,6 +7,7 @@ mode: custom
<div className="cl-page">
<div className="cl-sidebar">
<p className="cl-sidebar-title">TIMELINE</p>
<a href="#week-of-june-30%2C-2026" className="cl-sidebar-link">Week of Jun 30, 2026</a>
<a href="#week-of-june-22%2C-2026" className="cl-sidebar-link">Week of Jun 22, 2026</a>
<a href="#week-of-june-15%2C-2026" className="cl-sidebar-link">Week of Jun 15, 2026</a>
<a href="#week-of-june-8%2C-2026" className="cl-sidebar-link">Week of Jun 8, 2026</a>
@ -19,18 +20,36 @@ mode: custom
<a href="#week-of-april-21%2C-2026" className="cl-sidebar-link">Week of Apr 21, 2026</a>
<a href="#week-of-april-14%2C-2026" className="cl-sidebar-link">Week of Apr 14, 2026</a>
<a href="#week-of-march-30%2C-2026" className="cl-sidebar-link">Week of Mar 30, 2026</a>
<a href="#v1-0-22-—-february-26%2C-2026" className="cl-sidebar-link">v1.0.22 — Feb 26, 2026</a>
<a href="#v1-0-21-—-february-24%2C-2026" className="cl-sidebar-link">v1.0.21 — Feb 24, 2026</a>
<a href="#v1-0-18-—-february-19%2C-2026" className="cl-sidebar-link">v1.0.18 — Feb 19, 2026</a>
<a href="#v1-0-17-—-february-19%2C-2026" className="cl-sidebar-link">v1.0.17 — Feb 19, 2026</a>
<a href="#v1-0-16-—-february-19%2C-2026" className="cl-sidebar-link">v1.0.16 — Feb 19, 2026</a>
<a href="#v1-0-15-—-february-17%2C-2026" className="cl-sidebar-link">v1.0.15 — Feb 17, 2026</a>
<a href="#week-of-february-23%2C-2026" className="cl-sidebar-link">Week of Feb 23, 2026</a>
<a href="#week-of-february-16%2C-2026" className="cl-sidebar-link">Week of Feb 16, 2026</a>
</div>
<div className="cl-content">
<h1 className="cl-title">Changelog</h1>
<p className="cl-subtitle">New features, improvements, and fixes in Skyvern</p>
<Update label="Week of June 30, 2026" description="Workflow Studio UX improvements, faster enrichment, code-block self-heal, extraction accuracy, bug fixes">
## 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))
</Update>
<Update label="Week of June 22, 2026" description="Workflow Studio preview, new models, label management, Gmail OTP, Copilot improvements, analytics filters, bug fixes">
## New features
@ -473,37 +492,34 @@ mode: custom
</Update>
<Update label="v1.0.22 — February 26, 2026" description="Adaptive caching, Workflow Trigger block, CLI signup, and more">
<Update label="Week of February 23, 2026" description="Adaptive caching, Workflow Trigger block, CLI signup, reserved parameters, new LLM models">
## 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
@ -515,67 +531,22 @@ mode: custom
- 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))
</Update>
<Update label="v1.0.21 — February 24, 2026" description="Reserved parameters, data_schema for loops, new LLM models">
## 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))
</Update>
<Update label="v1.0.18 — February 19, 2026" description="Browser profile testing and saved-profile workflows">
<Update label="Week of February 16, 2026" description="Browser profile testing, Skills package, 2FA detection, full CLI parity, Claude Opus 4.6">
## 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))
</Update>
<Update label="v1.0.17 — February 19, 2026" description="Skills package and cached script management">
## 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))
</Update>
<Update label="v1.0.16 — February 19, 2026" description="2FA detection, full CLI parity, Claude Opus 4.6 CUA">
## 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))
- **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))
## Improvements
@ -583,17 +554,10 @@ mode: custom
## 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))
</Update>
<Update label="v1.0.15 — February 17, 2026" description="Claude Opus 4.6 model support">
## 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))
</Update>
</div>

View file

@ -3343,10 +3343,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",
@ -8301,6 +8301,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",
@ -9255,6 +9311,37 @@
],
"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"
},
{
"type": "null"
}
],
"title": "Proxy Session Id",
"description": "Explicit sticky-session id for this profile's pinned proxy identity."
}
},
"type": "object",
@ -9394,10 +9481,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",
@ -9678,7 +9803,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": [
@ -9767,6 +9892,7 @@
"CredentialVaultType": {
"type": "string",
"enum": [
"skyvern",
"bitwarden",
"azure_vault",
"gcp",
@ -18656,6 +18782,16 @@
"type": "boolean",
"title": "Script Run",
"default": false
},
"trigger_type": {
"anyOf": [
{
"$ref": "#/components/schemas/WorkflowRunTriggerType"
},
{
"type": "null"
}
]
}
},
"type": "object",
@ -20339,6 +20475,43 @@
],
"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",
@ -25044,12 +25217,13 @@
"type": "string",
"enum": [
"manual",
"mcp",
"api",
"scheduled",
"webhook"
],
"title": "WorkflowRunTriggerType",
"description": "How a workflow run was initiated.\n\n- manual: User clicked \"Run\" in the UI\n- api: Direct API call to the run endpoint\n- scheduled: Triggered by a cron schedule\n- webhook: Triggered by an external system via the webhook endpoint"
"description": "How a workflow run was initiated.\n\n- manual: User clicked \"Run\" in the UI\n- mcp: First-party MCP client request\n- api: Direct API call to the run endpoint\n- scheduled: Triggered by a cron schedule\n- webhook: Triggered by an external system via the webhook endpoint"
},
"WorkflowSchedule": {
"properties": {

View file

@ -1,286 +0,0 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Skyvern Langchain](#skyvern-langchain)
- [Installation](#installation)
- [Basic Usage](#basic-usage)
- [Run a task(sync) locally in your local environment](#run-a-tasksync-locally-in-your-local-environment)
- [Run a task(async) locally in your local environment](#run-a-taskasync-locally-in-your-local-environment)
- [Get a task locally in your local environment](#get-a-task-locally-in-your-local-environment)
- [Run a task(sync) by calling skyvern APIs](#run-a-tasksync-by-calling-skyvern-apis)
- [Run a task(async) by calling skyvern APIs](#run-a-taskasync-by-calling-skyvern-apis)
- [Get a task by calling skyvern APIs](#get-a-task-by-calling-skyvern-apis)
- [Agent Usage](#agent-usage)
- [Run a task(async) locally in your local environment and wait until the task is finished](#run-a-taskasync-locally-in-your-local-environment-and-wait-until-the-task-is-finished)
- [Run a task(async) by calling skyvern APIs and wait until the task is finished](#run-a-taskasync-by-calling-skyvern-apis-and-wait-until-the-task-is-finished)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
# Skyvern Langchain
This is a langchain integration for Skyvern.
## Installation
```bash
pip install skyvern-langchain
```
To run the example scenarios, you might need to install other langchain dependencies.
```bash
pip install langchain-openai
pip install langchain-community
```
## Basic Usage
This is the only basic usage of skyvern langchain tool. If you want a full langchain integration experience, please refer to the [Agent Usage](#agent-usage) section to play with langchain agent.
Go to [Langchain Tools](https://python.langchain.com/v0.1/docs/modules/tools/) to see more advanced langchain tool usage.
### Run a task(sync) locally in your local environment
> sync task won't return until the task is finished.
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
```python
import asyncio
from skyvern_langchain.agent import RunTask
run_task = RunTask()
async def main():
# to run skyvern agent locally, must run `skyvern init` first
print(await run_task.ainvoke("Navigate to the Hacker News homepage and get the top 3 posts."))
if __name__ == "__main__":
asyncio.run(main())
```
### Run a task(async) locally in your local environment
> async task will return immediately and the task will be running in the background.
:warning: :warning: if you want to run the task in the background, you need to keep the script running until the task is finished, otherwise the task will be killed when the script is finished.
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
```python
import asyncio
from skyvern_langchain.agent import DispatchTask
dispatch_task = DispatchTask()
async def main():
# to run skyvern agent locally, must run `skyvern init` first
print(await dispatch_task.ainvoke("Navigate to the Hacker News homepage and get the top 3 posts."))
# keep the script running until the task is finished
await asyncio.sleep(600)
if __name__ == "__main__":
asyncio.run(main())
```
### Get a task locally in your local environment
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
```python
import asyncio
from skyvern_langchain.agent import GetTask
get_task = GetTask()
async def main():
# to run skyvern agent locally, must run `skyvern init` first
print(await get_task.ainvoke("<task_id>"))
if __name__ == "__main__":
asyncio.run(main())
```
### Run a task(sync) by calling skyvern APIs
> sync task won't return until the task is finished.
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
```python
import asyncio
from skyvern_langchain.client import RunTask
run_task = RunTask(
api_key="<your_organization_api_key>",
)
# or you can load the api_key from SKYVERN_API_KEY in .env
# run_task = RunTask()
async def main():
print(await run_task.ainvoke("Navigate to the Hacker News homepage and get the top 3 posts."))
if __name__ == "__main__":
asyncio.run(main())
```
### Run a task(async) by calling skyvern APIs
> async task will return immediately and the task will be running in the background.
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
the task is actually running in the skyvern cloud service, so you don't need to keep your script running until the task is finished.
```python
import asyncio
from skyvern_langchain.client import DispatchTask
dispatch_task = DispatchTask(
api_key="<your_organization_api_key>",
)
# or you can load the api_key from SKYVERN_API_KEY in .env
# dispatch_task = DispatchTask()
async def main():
print(await dispatch_task.ainvoke("Navigate to the Hacker News homepage and get the top 3 posts."))
if __name__ == "__main__":
asyncio.run(main())
```
### Get a task by calling skyvern APIs
> async task will return immediately and the task will be running in the background.
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
the task is actually running in the skyvern cloud service, so you don't need to keep your script running until the task is finished.
```python
import asyncio
from skyvern_langchain.client import GetTask
get_task = GetTask(
api_key="<your_organization_api_key>",
)
# or you can load the api_key from SKYVERN_API_KEY in .env
# get_task = GetTask()
async def main():
print(await get_task.ainvoke("<task_id>"))
if __name__ == "__main__":
asyncio.run(main())
```
## Agent Usage
Langchain is more powerful when used with [Langchain Agents](https://python.langchain.com/v0.1/docs/modules/agents/).
The following two examples show how to build an agent that executes a specified task, waits for its completion, and then returns the results. For example, the agent is tasked with navigating to the Hacker News homepage and retrieving the top three posts.
### Run a task(async) locally in your local environment and wait until the task is finished
> async task will return immediately and the task will be running in the background. You can use `GetTask` tool to poll the task information until the task is finished.
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
```python
import asyncio
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain.agents import initialize_agent, AgentType
from skyvern_langchain.agent import DispatchTask, GetTask
from langchain_community.tools.sleep.tool import SleepTool
# load OpenAI API key from .env
load_dotenv()
llm = ChatOpenAI(model="gpt-4o", temperature=0)
dispatch_task = DispatchTask()
get_task = GetTask()
agent = initialize_agent(
llm=llm,
tools=[
dispatch_task,
get_task,
SleepTool(),
],
verbose=True,
agent=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION,
)
async def main():
# use sleep tool to set up the polling logic until the task is completed, if you only want to dispatch a task, you can remove the sleep tool
print(await agent.ainvoke("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, get this task information until it's completed. The task information re-get interval should be 60s."))
if __name__ == "__main__":
asyncio.run(main())
```
### Run a task(async) by calling skyvern APIs and wait until the task is finished
> async task will return immediately and the task will be running in the background. You can use `GetTask` tool to poll the task information until the task is finished.
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
```python
import asyncio
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain.agents import initialize_agent, AgentType
from skyvern_langchain.client import DispatchTask, GetTask
from langchain_community.tools.sleep.tool import SleepTool
# load OpenAI API key from .env
load_dotenv()
llm = ChatOpenAI(model="gpt-4o", temperature=0)
dispatch_task = DispatchTask(
api_key="<your_organization_api_key>",
)
# or you can load the api_key from SKYVERN_API_KEY in .env
# dispatch_task = DispatchTask()
get_task = GetTask(
api_key="<your_organization_api_key>",
)
# or you can load the api_key from SKYVERN_API_KEY in .env
# get_task = GetTask()
agent = initialize_agent(
llm=llm,
tools=[
dispatch_task,
get_task,
SleepTool(),
],
verbose=True,
agent=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION,
)
async def main():
# use sleep tool to set up the polling logic until the task is completed, if you only want to dispatch a task, you can remove the sleep tool
print(await agent.ainvoke("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, get this task information until it's completed. The task information re-get interval should be 60s."))
if __name__ == "__main__":
asyncio.run(main())
```

View file

@ -1,53 +0,0 @@
[project]
name = "skyvern-langchain"
version = "0.2.1"
description = ""
authors = [{ name = "lawyzheng", email = "lawy@skyvern.com" }]
requires-python = ">=3.11,<3.14"
readme = "README.md"
dependencies = [
"skyvern>=0.2.0",
"langchain>=1.2.0",
"langchain-core>=1.3.3",
"urllib3>=2.7.0",
]
[tool.uv]
override-dependencies = [
"authlib>=1.6.11",
"pyasn1>=0.6.3",
"pyjwt>=2.12.0",
"fastmcp>=3.2.0",
"mcp>=1.23.0",
"websockets>=15.0.1",
# litellm 1.83.7+ pins these exactly; relax for security upgrade.
"jsonschema>=4.23.0",
"python-dotenv>=1.2.2",
"openai>=2.24.0",
# Dependabot moderate security upgrades (transitive)
"mako>=1.3.11",
"pypdf>=6.10.2",
"python-multipart>=0.0.26",
"cryptography>=46.0.7",
"pygments>=2.20.0",
# Dependabot high security upgrades (transitive)
"litellm>=1.83.10",
"langsmith>=0.8.0",
]
[tool.uv.sources]
skyvern = { path = "../.." }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = ["twine>=6.1.0,<7"]
[tool.hatch.build.targets.sdist]
include = ["skyvern_langchain"]
[tool.hatch.build.targets.wheel]
include = ["skyvern_langchain"]

View file

@ -1,60 +0,0 @@
from typing import Any, Type
from langchain.tools import BaseTool
from litellm import BaseModel
from pydantic import Field
from skyvern_langchain.schema import CreateTaskInput, GetTaskInput
from skyvern_langchain.settings import settings
from skyvern import Skyvern
from skyvern.client.types.get_run_response import GetRunResponse
from skyvern.client.types.task_run_response import TaskRunResponse
from skyvern.schemas.runs import RunEngine
class SkyvernTaskBaseTool(BaseTool):
engine: RunEngine = Field(default=settings.engine)
run_task_timeout_seconds: int = Field(default=settings.run_task_timeout_seconds)
agent: Skyvern = Skyvern.local()
def _run(self, *args: Any, **kwargs: Any) -> None:
raise NotImplementedError("skyvern task tool does not support sync")
class RunTask(SkyvernTaskBaseTool):
name: str = "run-skyvern-agent-task"
description: str = """Use Skyvern agent to run a task. This function won't return until the task is finished."""
args_schema: Type[BaseModel] = CreateTaskInput
async def _arun(self, user_prompt: str, url: str | None = None) -> TaskRunResponse:
return await self.agent.run_task(
prompt=user_prompt,
url=url,
engine=self.engine,
timeout=self.run_task_timeout_seconds,
wait_for_completion=True,
)
class DispatchTask(SkyvernTaskBaseTool):
name: str = "dispatch-skyvern-agent-task"
description: str = """Use Skyvern agent to dispatch a task. This function will return immediately and the task will be running in the background."""
args_schema: Type[BaseModel] = CreateTaskInput
async def _arun(self, user_prompt: str, url: str | None = None) -> TaskRunResponse:
return await self.agent.run_task(
prompt=user_prompt,
url=url,
engine=self.engine,
timeout=self.run_task_timeout_seconds,
wait_for_completion=False,
)
class GetTask(SkyvernTaskBaseTool):
name: str = "get-skyvern-agent-task"
description: str = """Use Skyvern agent to get a task."""
args_schema: Type[BaseModel] = GetTaskInput
async def _arun(self, task_id: str) -> GetRunResponse | None:
return await self.agent.get_run(run_id=task_id)

View file

@ -1,64 +0,0 @@
from typing import Any, Type
from langchain.tools import BaseTool
from pydantic import BaseModel, Field
from skyvern_langchain.schema import CreateTaskInput, GetTaskInput
from skyvern_langchain.settings import settings
from skyvern import Skyvern
from skyvern.client import SkyvernEnvironment
from skyvern.client.types.get_run_response import GetRunResponse
from skyvern.client.types.task_run_response import TaskRunResponse
from skyvern.schemas.runs import RunEngine
class SkyvernTaskBaseTool(BaseTool):
api_key: str = Field(default=settings.api_key)
base_url: str = Field(default=settings.base_url)
engine: RunEngine = Field(default=settings.engine)
run_task_timeout_seconds: int = Field(default=settings.run_task_timeout_seconds)
def get_client(self) -> Skyvern:
return Skyvern(environment=SkyvernEnvironment.CLOUD, base_url=self.base_url, api_key=self.api_key)
def _run(self, *args: Any, **kwargs: Any) -> None:
raise NotImplementedError("skyvern task tool does not support sync")
class RunTask(SkyvernTaskBaseTool):
name: str = "run-skyvern-client-task"
description: str = """Use Skyvern client to run a task. This function won't return until the task is finished."""
args_schema: Type[BaseModel] = CreateTaskInput
async def _arun(self, user_prompt: str, url: str | None = None) -> TaskRunResponse:
return await self.get_client().run_task(
timeout=self.run_task_timeout_seconds,
url=url,
prompt=user_prompt,
engine=self.engine,
wait_for_completion=True,
)
class DispatchTask(SkyvernTaskBaseTool):
name: str = "dispatch-skyvern-client-task"
description: str = """Use Skyvern client to dispatch a task. This function will return immediately and the task will be running in the background."""
args_schema: Type[BaseModel] = CreateTaskInput
async def _arun(self, user_prompt: str, url: str | None = None) -> TaskRunResponse:
return await self.get_client().run_task(
timeout=self.run_task_timeout_seconds,
url=url,
prompt=user_prompt,
engine=self.engine,
wait_for_completion=False,
)
class GetTask(SkyvernTaskBaseTool):
name: str = "get-skyvern-client-task"
description: str = """Use Skyvern client to get a task."""
args_schema: Type[BaseModel] = GetTaskInput
async def _arun(self, task_id: str) -> GetRunResponse | None:
return await self.get_client().get_run(run_id=task_id)

View file

@ -1,10 +0,0 @@
from pydantic import BaseModel
class CreateTaskInput(BaseModel):
user_prompt: str
url: str | None = None
class GetTaskInput(BaseModel):
task_id: str

View file

@ -1,18 +0,0 @@
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from skyvern.schemas.runs import RunEngine
class Settings(BaseSettings):
api_key: str = ""
base_url: str = "https://api.skyvern.com"
engine: RunEngine = RunEngine.skyvern_v2
run_task_timeout_seconds: int = 60 * 60
class Config:
env_prefix = "SKYVERN_"
load_dotenv()
settings = Settings()

File diff suppressed because it is too large Load diff

View file

@ -1,294 +0,0 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Skyvern LlamaIndex](#skyvern-llamaindex)
- [Installation](#installation)
- [Basic Usage](#basic-usage)
- [Run a task(sync) locally in your local environment](#run-a-tasksync-locally-in-your-local-environment)
- [Run a task(async) locally in your local environment](#run-a-taskasync-locally-in-your-local-environment)
- [Get a task locally in your local environment](#get-a-task-locally-in-your-local-environment)
- [Run a task(sync) by calling skyvern APIs](#run-a-tasksync-by-calling-skyvern-apis)
- [Run a task(async) by calling skyvern APIs](#run-a-taskasync-by-calling-skyvern-apis)
- [Get a task by calling skyvern APIs](#get-a-task-by-calling-skyvern-apis)
- [Advanced Usage](#advanced-usage)
- [Dispatch a task(async) locally in your local environment and wait until the task is finished](#dispatch-a-taskasync-locally-in-your-local-environment-and-wait-until-the-task-is-finished)
- [Dispatch a task(async) by calling skyvern APIs and wait until the task is finished](#dispatch-a-taskasync-by-calling-skyvern-apis-and-wait-until-the-task-is-finished)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
# Skyvern LlamaIndex
This is a LlamaIndex integration for Skyvern.
## Installation
```bash
pip install skyvern-llamaindex
```
## Basic Usage
### Run a task(sync) locally in your local environment
> sync task won't return until the task is finished.
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
```python
from dotenv import load_dotenv
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI
from skyvern_llamaindex.agent import SkyvernTool
# load OpenAI API key from .env
load_dotenv()
skyvern_tool = SkyvernTool()
agent = OpenAIAgent.from_tools(
tools=[skyvern_tool.run_task()],
llm=OpenAI(model="gpt-4o"),
verbose=True,
)
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.'")
print(response)
```
### Run a task(async) locally in your local environment
> async task will return immediately and the task will be running in the background.
:warning: :warning: if you want to run the task in the background, you need to keep the agent running until the task is finished, otherwise the task will be killed when the agent finished the chat.
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
```python
import asyncio
from dotenv import load_dotenv
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI
from skyvern_llamaindex.agent import SkyvernTool
from llama_index.core.tools import FunctionTool
# load OpenAI API key from .env
load_dotenv()
async def sleep(seconds: int) -> str:
await asyncio.sleep(seconds)
return f"Slept for {seconds} seconds"
# define a sleep tool to keep the agent running until the task is finished
sleep_tool = FunctionTool.from_defaults(
async_fn=sleep,
description="Sleep for a given number of seconds",
name="sleep",
)
skyvern_tool = SkyvernTool()
agent = OpenAIAgent.from_tools(
tools=[skyvern_tool.dispatch_task(), sleep_tool],
llm=OpenAI(model="gpt-4o"),
verbose=True,
)
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, sleep for 10 minutes.")
print(response)
```
### Get a task locally in your local environment
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
```python
from dotenv import load_dotenv
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI
from skyvern_llamaindex.agent import SkyvernTool
# load OpenAI API key from .env
load_dotenv()
skyvern_tool = SkyvernTool()
agent = OpenAIAgent.from_tools(
tools=[skyvern_tool.get_task()],
llm=OpenAI(model="gpt-4o"),
verbose=True,
)
response = agent.chat("Get the task information with Skyvern. The task id is '<task_id>'.")
print(response)
```
### Run a task(sync) by calling skyvern APIs
> sync task won't return until the task is finished.
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
```python
from dotenv import load_dotenv
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI
from skyvern_llamaindex.client import SkyvernTool
# load OpenAI API key from .env
load_dotenv()
skyvern_tool = SkyvernTool(api_key="<your_organization_api_key>")
# or you can load the api_key from SKYVERN_API_KEY in .env
# skyvern_tool = SkyvernTool()
agent = OpenAIAgent.from_tools(
tools=[skyvern_tool.run_task()],
llm=OpenAI(model="gpt-4o"),
verbose=True,
)
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.'")
print(response)
```
### Run a task(async) by calling skyvern APIs
> async task will return immediately and the task will be running in the background.
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
the task is actually running in the skyvern cloud service, so you don't need to keep your agent running until the task is finished.
```python
from dotenv import load_dotenv
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI
from skyvern_llamaindex.client import SkyvernTool
# load OpenAI API key from .env
load_dotenv()
skyvern_tool = SkyvernTool(api_key="<your_organization_api_key>")
# or you can load the api_key from SKYVERN_API_KEY in .env
# skyvern_tool = SkyvernTool()
agent = OpenAIAgent.from_tools(
tools=[skyvern_tool.dispatch_task()],
llm=OpenAI(model="gpt-4o"),
verbose=True,
)
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.'")
print(response)
```
### Get a task by calling skyvern APIs
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
```python
from dotenv import load_dotenv
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI
from skyvern_llamaindex.client import SkyvernTool
# load OpenAI API key from .env
load_dotenv()
skyvern_tool = SkyvernTool(api_key="<your_organization_api_key>")
# or you can load the api_key from SKYVERN_API_KEY in .env
# skyvern_tool = SkyvernTool()
agent = OpenAIAgent.from_tools(
tools=[skyvern_tool.get_task()],
llm=OpenAI(model="gpt-4o"),
verbose=True,
)
response = agent.chat("Get the task information with Skyvern. The task id is '<task_id>'.")
print(response)
```
## Advanced Usage
To provide some examples of how to integrate Skyvern with other llama-index tools in the agent.
### Dispatch a task(async) locally in your local environment and wait until the task is finished
> dispatch task will return immediately and the task will be running in the background. You can use `get_task` tool to poll the task information until the task is finished.
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
```python
import asyncio
from dotenv import load_dotenv
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI
from llama_index.core.tools import FunctionTool
from skyvern_llamaindex.agent import SkyvernTool
# load OpenAI API key from .env
load_dotenv()
async def sleep(seconds: int) -> str:
await asyncio.sleep(seconds)
return f"Slept for {seconds} seconds"
sleep_tool = FunctionTool.from_defaults(
async_fn=sleep,
description="Sleep for a given number of seconds",
name="sleep",
)
skyvern_tool = SkyvernTool()
agent = OpenAIAgent.from_tools(
tools=[skyvern_tool.dispatch_task(), skyvern_tool.get_task(), sleep_tool],
llm=OpenAI(model="gpt-4o"),
verbose=True,
max_function_calls=10,
)
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, get this task information until it's completed. The task information re-get interval should be 60s.")
print(response)
```
### Dispatch a task(async) by calling skyvern APIs and wait until the task is finished
> dispatch task will return immediately and the task will be running in the background. You can use `get_task` tool to poll the task information until the task is finished.
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
```python
import asyncio
from dotenv import load_dotenv
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI
from llama_index.core.tools import FunctionTool
from skyvern_llamaindex.client import SkyvernTool
# load OpenAI API key from .env
load_dotenv()
async def sleep(seconds: int) -> str:
await asyncio.sleep(seconds)
return f"Slept for {seconds} seconds"
sleep_tool = FunctionTool.from_defaults(
async_fn=sleep,
description="Sleep for a given number of seconds",
name="sleep",
)
skyvern_tool = SkyvernTool(api_key="<your_organization_api_key>")
# or you can load the api_key from SKYVERN_API_KEY in .env
# skyvern_tool = SkyvernTool()
agent = OpenAIAgent.from_tools(
tools=[skyvern_tool.dispatch_task(), skyvern_tool.get_task(), sleep_tool],
llm=OpenAI(model="gpt-4o"),
verbose=True,
max_function_calls=10,
)
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, get this task information until it's completed. The task information re-get interval should be 60s.")
print(response)
```

View file

@ -1,66 +0,0 @@
[project]
name = "skyvern-llamaindex"
version = "0.2.2"
description = "Skyvern integration for LlamaIndex"
authors = [{ name = "lawyzheng", email = "lawy@skyvern.com" }]
requires-python = ">=3.11,<3.14"
readme = "README.md"
dependencies = [
"skyvern>=0.2.0",
"llama-index>=0.12.19,<0.14",
# Dependabot #645 (GHSA-qccp-gfcp-xxvc / CVE-2026-44431): urllib3 < 2.7.0
# forwards sensitive headers across origins on proxied low-level redirects.
"urllib3>=2.7.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = ["twine>=6.1.0,<7"]
# Security: override vulnerable transitive dependency versions (SKY-8441)
# Using override-dependencies because several of these conflict with
# skyvern's pinned ranges (e.g. pypdf<6, python-multipart<0.0.19).
[tool.uv]
override-dependencies = [
"authlib>=1.6.11",
"pyasn1>=0.6.3",
"PyJWT>=2.12.0",
"fastmcp>=3.2.0",
"mcp>=1.23.0",
"pypdf>=6.10.2",
"pillow>=12.1.1",
"nltk>=3.9.3",
"python-multipart>=0.0.26",
"starlette>=0.49.1",
"google-cloud-aiplatform>=1.133.0",
# Cascading overrides needed to unblock the above security constraints:
# fastmcp>=3.2.0 requires websockets>=15 (skyvern pins <13)
"websockets>=15.0.1",
# litellm 1.83.7+ pins openai==2.24.0 (security upgrade); override
# llama-index-llms-openai's openai<2 declaration so the integration can
# take the patched litellm.
"openai>=2.24.0",
# litellm 1.83.7+ also pins these exactly.
"jsonschema>=4.23.0",
"python-dotenv>=1.0.0",
# Dependabot moderate security upgrades (transitive)
"mako>=1.3.11",
"cryptography>=46.0.7",
"pygments>=2.20.0",
# Dependabot #631 (GHSA-gphh-9q3h-jgpp / CVE-2026-44209): banks <= 2.4.1
# renders prompt templates with an unsandboxed Jinja2 environment (SSTI -> RCE).
# Pulled in transitively via llama-index-core. Fixed in 2.4.2.
"banks>=2.4.2",
]
[tool.uv.sources]
skyvern = { path = "../.." }
[tool.hatch.build.targets.sdist]
include = ["skyvern_llamaindex"]
[tool.hatch.build.targets.wheel]
include = ["skyvern_llamaindex"]

View file

@ -1,129 +0,0 @@
from typing import Any, List
from llama_index.core.tools import FunctionTool
from llama_index.core.tools.tool_spec.base import SPEC_FUNCTION_TYPE, BaseToolSpec
from skyvern_llamaindex.settings import settings
from skyvern import Skyvern
from skyvern.client.types.get_run_response import GetRunResponse
from skyvern.client.types.task_run_response import TaskRunResponse
from skyvern.schemas.runs import RunEngine
class SkyvernTool:
def __init__(self, agent: Skyvern | None = None):
if agent is None:
agent = Skyvern.local()
self.agent = agent
def run_task(self) -> FunctionTool:
task_tool_spec = SkyvernTaskToolSpec(agent=self.agent)
return task_tool_spec.to_tool_list(["run_task"])[0]
def dispatch_task(self) -> FunctionTool:
task_tool_spec = SkyvernTaskToolSpec(agent=self.agent)
return task_tool_spec.to_tool_list(["dispatch_task"])[0]
def get_task(self) -> FunctionTool:
task_tool_spec = SkyvernTaskToolSpec(agent=self.agent)
return task_tool_spec.to_tool_list(["get_task"])[0]
class SkyvernTaskToolSpec(BaseToolSpec):
spec_functions: List[SPEC_FUNCTION_TYPE] = [
"run_task",
"dispatch_task",
"get_task",
]
def __init__(
self,
*,
agent: Skyvern | None = None,
engine: RunEngine = settings.engine,
run_task_timeout_seconds: int = settings.run_task_timeout_seconds,
) -> None:
if agent is None:
agent = Skyvern.local()
self.agent = agent
self.engine = engine
self.run_task_timeout_seconds = run_task_timeout_seconds
async def run_task(
self,
user_prompt: str | None = None,
url: str | None = None,
*_: Any,
**kw: Any,
) -> TaskRunResponse:
"""
Use Skyvern agent to run a task. This function won't return until the task is finished.
Args:
user_prompt[str]: The user's prompt describing the task.
url (Optional[str]): The URL of the target website for the task.
"""
if user_prompt is None and kw.get("args"):
user_prompt = kw["args"][0]
if url is None:
if kw.get("args") and len(kw["args"]) > 1:
url = kw["args"][1]
elif kw.get("kwargs"):
url = kw["kwargs"].get("url")
assert user_prompt is not None, "user_prompt is required"
return await self.agent.run_task(
prompt=user_prompt,
url=url,
engine=self.engine,
timeout=self.run_task_timeout_seconds,
wait_for_completion=True,
)
async def dispatch_task(
self,
user_prompt: str | None = None,
url: str | None = None,
*_: Any,
**kw: Any,
) -> TaskRunResponse:
"""
Use Skyvern agent to dispatch a task. This function will return immediately and the task will be running in the background.
Args:
user_prompt[str]: The user's prompt describing the task.
url (Optional[str]): The URL of the target website for the task.
"""
if user_prompt is None and kw.get("args"):
user_prompt = kw["args"][0]
if url is None:
if kw.get("args") and len(kw["args"]) > 1:
url = kw["args"][1]
elif kw.get("kwargs"):
url = kw["kwargs"].get("url")
assert user_prompt is not None, "user_prompt is required"
return await self.agent.run_task(
prompt=user_prompt,
url=url,
engine=self.engine,
timeout=self.run_task_timeout_seconds,
wait_for_completion=False,
)
async def get_task(self, task_id: str | None = None, *_: Any, **kwargs: Any) -> GetRunResponse | None:
"""
Use Skyvern agent to get a task.
Args:
task_id[str]: The id of the task.
"""
if task_id is None and "args" in kwargs:
task_id = kwargs["args"][0]
assert task_id is not None, "task_id is required"
return await self.agent.get_run(run_id=task_id)

View file

@ -1,139 +0,0 @@
from typing import Any, List
from llama_index.core.tools import FunctionTool
from llama_index.core.tools.tool_spec.base import SPEC_FUNCTION_TYPE, BaseToolSpec
from pydantic import BaseModel
from skyvern_llamaindex.settings import settings
from skyvern import Skyvern
from skyvern.client import SkyvernEnvironment
from skyvern.client.types.get_run_response import GetRunResponse
from skyvern.client.types.task_run_response import TaskRunResponse
from skyvern.schemas.runs import RunEngine
class SkyvernTool(BaseModel):
api_key: str = settings.api_key
base_url: str = settings.base_url
def run_task(self) -> FunctionTool:
task_tool_spec = SkyvernTaskToolSpec(
api_key=self.api_key,
base_url=self.base_url,
)
return task_tool_spec.to_tool_list(["run_task"])[0]
def dispatch_task(self) -> FunctionTool:
task_tool_spec = SkyvernTaskToolSpec(
api_key=self.api_key,
base_url=self.base_url,
)
return task_tool_spec.to_tool_list(["dispatch_task"])[0]
def get_task(self) -> FunctionTool:
task_tool_spec = SkyvernTaskToolSpec(
api_key=self.api_key,
base_url=self.base_url,
)
return task_tool_spec.to_tool_list(["get_task"])[0]
class SkyvernTaskToolSpec(BaseToolSpec):
spec_functions: List[SPEC_FUNCTION_TYPE] = [
"run_task",
"dispatch_task",
"get_task",
]
def __init__(
self,
*,
api_key: str = settings.api_key,
base_url: str = settings.base_url,
engine: RunEngine = settings.engine,
run_task_timeout_seconds: int = settings.run_task_timeout_seconds,
):
self.engine = engine
self.run_task_timeout_seconds = run_task_timeout_seconds
self.client = Skyvern(environment=SkyvernEnvironment.CLOUD, base_url=base_url, api_key=api_key)
async def run_task(
self,
user_prompt: str | None = None,
url: str | None = None,
*_: Any,
**kw: Any,
) -> TaskRunResponse:
"""
Use Skyvern client to run a task. This function won't return until the task is finished.
Args:
user_prompt[str]: The user's prompt describing the task.
url (Optional[str]): The URL of the target website for the task.
"""
if user_prompt is None and kw.get("args"):
user_prompt = kw["args"][0]
if url is None:
if kw.get("args") and len(kw["args"]) > 1:
url = kw["args"][1]
elif kw.get("kwargs"):
url = kw["kwargs"].get("url")
assert user_prompt is not None, "user_prompt is required"
return await self.client.run_task(
prompt=user_prompt,
url=url,
engine=self.engine,
timeout=self.run_task_timeout_seconds,
wait_for_completion=True,
)
async def dispatch_task(
self,
user_prompt: str | None = None,
url: str | None = None,
*_: Any,
**kw: Any,
) -> TaskRunResponse:
"""
Use Skyvern client to dispatch a task. This function will return immediately and the task will be running in the background.
Args:
user_prompt[str]: The user's prompt describing the task.
url (Optional[str]): The URL of the target website for the task.
"""
if user_prompt is None and kw.get("args"):
user_prompt = kw["args"][0]
if url is None:
if kw.get("args") and len(kw["args"]) > 1:
url = kw["args"][1]
elif kw.get("kwargs"):
url = kw["kwargs"].get("url")
assert user_prompt is not None, "user_prompt is required"
return await self.client.run_task(
prompt=user_prompt,
url=url,
engine=self.engine,
timeout=self.run_task_timeout_seconds,
wait_for_completion=False,
)
async def get_task(self, task_id: str | None = None, *_: Any, **kwargs: Any) -> GetRunResponse | None:
"""
Use Skyvern client to get a task.
Args:
task_id[str]: The id of the task.
"""
if task_id is None and "args" in kwargs:
task_id = kwargs["args"][0]
assert task_id is not None, "task_id is required"
return await self.client.get_run(run_id=task_id)

View file

@ -1,18 +0,0 @@
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from skyvern.schemas.runs import RunEngine
class Settings(BaseSettings):
api_key: str = ""
base_url: str = "https://api.skyvern.com"
engine: RunEngine = RunEngine.skyvern_v2
run_task_timeout_seconds: int = 60 * 60
class Config:
env_prefix = "SKYVERN_"
load_dotenv()
settings = Settings()

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[project]
name = "skyvern-ui"
version = "1.0.44"
version = "1.0.46"
description = "Prebuilt Skyvern UI assets for the Skyvern CLI"
authors = [{ name = "Skyvern AI", email = "info@skyvern.com" }]
requires-python = ">=3.11,<3.14"

View file

@ -1,6 +1,6 @@
[project]
name = "skyvern"
version = "1.0.44"
version = "1.0.46"
description = ""
authors = [{ name = "Skyvern AI", email = "info@skyvern.com" }]
requires-python = ">=3.11,<3.14"
@ -135,7 +135,9 @@ server = [
"json-repair>=0.59.5,<0.60",
"pypdf>=6.12.0,<7",
"pdfplumber>=0.11.0,<0.12",
"fastmcp>=3.2.0,<4",
# <3.4: 3.4.3 enables HostOriginGuardMiddleware by default (421 on public hosts, SKY-12003).
# cloud_app passes allowed_hosts/allowed_origins when supported; validate against 3.4.x before raising.
"fastmcp>=3.2.0,<3.4",
"psutil>=7.0.0",
"tiktoken>=0.9.0",
"anthropic>=0.97.0,<0.98",
@ -281,10 +283,11 @@ constraint-dependencies = [
"cryptography>=48.0.1",
"flask>=3.1.3",
"idna>=3.15",
"joserfc>=1.6.3",
"jupyter-server>=2.18.0",
"joserfc>=1.6.8",
"jupyter-server>=2.20.0",
"mako>=1.3.12",
"mistune>=3.2.1",
"msgpack>=1.2.1",
"pyasn1>=0.6.3",
"starlette>=1.3.1",
"tornado>=6.5.6",

View file

@ -6,7 +6,10 @@
export DATABASE_STRING
# first apply migrations
export PATH="${PATH}:.venv/bin"
# Prepend the repo venv so its alembic wins over a global install (e.g. Homebrew's),
# which lacks the `cloud` package and would flag cloud tables as drift.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PATH="${SCRIPT_DIR}/.venv/bin:${PATH}"
alembic upgrade head
# then check if the database is up to date with the models
if ! alembic check; then

View file

@ -34,7 +34,7 @@ async def run() -> None:
try:
if workflow_run_id:
workflow_run = await app.DATABASE.get_workflow_run(workflow_run_id=workflow_run_id)
workflow_run = await app.DATABASE.workflow_runs.get_workflow_run(workflow_run_id=workflow_run_id)
if not workflow_run or workflow_run.status in [
WorkflowRunStatus.completed,
WorkflowRunStatus.failed,
@ -44,7 +44,7 @@ async def run() -> None:
file_name = f"{workflow_run_id}.png"
elif task_id:
task = await app.DATABASE.get_task(task_id=task_id, organization_id=organization_id)
task = await app.DATABASE.tasks.get_task(task_id=task_id, organization_id=organization_id)
if not task or task.status.is_final():
continue
file_name = f"{task_id}.png"

View file

@ -17,8 +17,12 @@ delegate_methods=$(
)
if [ -z "$delegate_methods" ]; then
echo "ERROR: Could not extract delegate methods from $AGENT_DB" >&2
exit 1
# The AgentDB *args backward-compat facade was removed entirely (SKY-11703).
# With no delegate methods left, there is nothing to police: a direct
# delegate call is now a plain AttributeError/mypy error, caught elsewhere.
# Nothing to check, so pass cleanly.
echo "No AgentDB delegate methods defined; nothing to check."
exit 0
fi
# Build a grep alternation pattern for delegate method names.

View file

@ -7,6 +7,9 @@ rm -fr skyvern/client
mkdir -p skyvern/client
cp -rf fern/.preview/fern-python-sdk/src/skyvern/* skyvern/client/
# Post-processing: Reapply manual patches (circular imports, forward-ref KeyError)
python3 scripts/patch_generated_client.py || exit 1
# Post-processing: Patch version.py to handle missing metadata gracefully
VERSION_FILE="skyvern/client/version.py"
if [ -f "$VERSION_FILE" ]; then

View file

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Reapply manual patches to the Fern-generated Python client.
Run automatically by scripts/fern_build_python_sdk.sh after every regen.
Idempotent: running it twice leaves the files unchanged.
Patches:
1. Loop-block circular imports Fern v4.31.1 emits bottom-cross-imports that
deadlock at module load (for_loop <-> while_loop <-> *_loop_blocks_item).
Wrap them in try/except ImportError; the symmetric *_loop_blocks_item module
back-resolves once both unions are fully defined.
2. update_forward_refs Pydantic v2 can raise internal schema-gathering
KeyErrors for Fern-generated recursive unions even with raise_errors=False.
Suppress KeyErrors that mention the "definitions" key.
"""
from __future__ import annotations
import sys
from pathlib import Path
CLIENT = Path(__file__).resolve().parent.parent / "skyvern" / "client"
LOOP_BLOCK_PATCHES = {
"types/for_loop_block.py": (
"""from .while_loop_block import WhileLoopBlock # noqa: E402, F401, I001
from .for_loop_block_loop_blocks_item import ForLoopBlockLoopBlocksItem # noqa: E402, F401, I001""",
"""
# Manual patch: Fern v4.31.1 emits bottom-cross-imports that deadlock at module
# load (for_loop <-> while_loop <-> *_loop_blocks_item). Catch the mid-load
# ImportError; the symmetric *_loop_blocks_item module back-resolves once both
# unions are fully defined. Reapplied on every regen by scripts/patch_generated_client.py.
try:
from .while_loop_block import WhileLoopBlock # noqa: E402, F401, I001
from .for_loop_block_loop_blocks_item import ForLoopBlockLoopBlocksItem # noqa: E402, F401, I001
except ImportError:
pass""",
),
"types/while_loop_block.py": (
"""from .for_loop_block import ForLoopBlock # noqa: E402, F401, I001
from .while_loop_block_loop_blocks_item import WhileLoopBlockLoopBlocksItem # noqa: E402, F401, I001""",
"""
# Manual patch: Fern v4.31.1 emits bottom-cross-imports that deadlock at module
# load. See for_loop_block.py for the explanation.
try:
from .for_loop_block import ForLoopBlock # noqa: E402, F401, I001
from .while_loop_block_loop_blocks_item import WhileLoopBlockLoopBlocksItem # noqa: E402, F401, I001
except ImportError:
pass""",
),
"types/for_loop_block_yaml.py": (
"""from .while_loop_block_yaml import WhileLoopBlockYaml # noqa: E402, F401, I001
from .for_loop_block_yaml_loop_blocks_item import ForLoopBlockYamlLoopBlocksItem # noqa: E402, F401, I001""",
"""
# Manual patch: Fern v4.31.1 emits bottom-cross-imports that deadlock at module
# load. See for_loop_block.py for the explanation.
try:
from .while_loop_block_yaml import WhileLoopBlockYaml # noqa: E402, F401, I001
from .for_loop_block_yaml_loop_blocks_item import ForLoopBlockYamlLoopBlocksItem # noqa: E402, F401, I001
except ImportError:
pass""",
),
"types/while_loop_block_yaml.py": (
"""from .for_loop_block_yaml import ForLoopBlockYaml # noqa: E402, F401, I001
from .while_loop_block_yaml_loop_blocks_item import WhileLoopBlockYamlLoopBlocksItem # noqa: E402, F401, I001""",
"""
# Manual patch: Fern v4.31.1 emits bottom-cross-imports that deadlock at module
# load. See for_loop_block.py for the explanation.
try:
from .for_loop_block_yaml import ForLoopBlockYaml # noqa: E402, F401, I001
from .while_loop_block_yaml_loop_blocks_item import WhileLoopBlockYamlLoopBlocksItem # noqa: E402, F401, I001
except ImportError:
pass""",
),
}
FORWARD_REFS_OLD = """def update_forward_refs(model: Type["Model"], **localns: Any) -> None:
if IS_PYDANTIC_V2:
model.model_rebuild(raise_errors=False) # type: ignore[attr-defined]
else:
model.update_forward_refs(**localns)"""
FORWARD_REFS_NEW = """def update_forward_refs(model: Type["Model"], **localns: Any) -> None:
if IS_PYDANTIC_V2:
try:
model.model_rebuild(raise_errors=False) # type: ignore[attr-defined]
except KeyError as exc:
# Manual patch (reapplied by scripts/patch_generated_client.py):
# Pydantic v2 can still raise internal schema-gathering KeyErrors
# for Fern-generated recursive unions even with raise_errors=False.
# Match on the "definitions" key rather than the exact args tuple so a
# Pydantic format change that adds context can't reintroduce the crash.
if "definitions" not in exc.args:
raise
else:
model.update_forward_refs(**localns)"""
def patch_file(rel_path: str, old: str, new: str) -> str:
path = CLIENT / rel_path
text = path.read_text()
if new in text:
return "already patched"
if old not in text:
return "PATTERN NOT FOUND"
path.write_text(text.replace(old, new, 1))
return "patched"
def main() -> int:
failed = False
for rel_path, (old, new) in LOOP_BLOCK_PATCHES.items():
result = patch_file(rel_path, old, new)
print(f"{rel_path}: {result}")
failed |= result == "PATTERN NOT FOUND"
result = patch_file("core/pydantic_utilities.py", FORWARD_REFS_OLD, FORWARD_REFS_NEW)
print(f"core/pydantic_utilities.py: {result}")
failed |= result == "PATTERN NOT FOUND"
if failed:
print(
"ERROR: some patch patterns no longer match the generated code. "
"The Fern generator output changed shape - update this script.",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -12,6 +12,7 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/merge": "^6.8.0",
"@dagrejs/dagre": "^1.1.4",
"@dnd-kit/core": "^6.3.1",
@ -97,7 +98,7 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.5.3",
"vite": "^6.4.2",
"vite": "^6.4.3",
"vitest": "^4.1.0"
}
},
@ -364,6 +365,21 @@
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
@ -1427,6 +1443,17 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
@ -6504,21 +6531,33 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -7192,6 +7231,16 @@
}
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -9788,16 +9837,6 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -9957,9 +9996,9 @@
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -23,6 +23,7 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/merge": "^6.8.0",
"@dagrejs/dagre": "^1.1.4",
"@dnd-kit/core": "^6.3.1",
@ -108,7 +109,7 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.5.3",
"vite": "^6.4.2",
"vite": "^6.4.3",
"vitest": "^4.1.0"
},
"lint-staged": {
@ -128,6 +129,7 @@
},
"@types/node": "$@types/node",
"protobufjs": ">=7.5.8",
"qs": ">=6.15.2"
"qs": ">=6.15.2",
"undici": "^7.28.0"
}
}

View file

@ -1,12 +1,26 @@
import { QueryClient } from "@tanstack/react-query";
import { isTransientNetworkError } from "./transientNetworkError";
const MAX_TRANSIENT_NETWORK_RETRIES = 2;
function retryTransientNetworkFailures(
failureCount: number,
error: unknown,
): boolean {
return (
isTransientNetworkError(error) &&
failureCount < MAX_TRANSIENT_NETWORK_RETRIES
);
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: false,
retry: retryTransientNetworkFailures,
},
},
});
export { queryClient };
export { queryClient, retryTransientNetworkFailures };

View file

@ -0,0 +1,67 @@
import { AxiosError, AxiosHeaders, CanceledError } from "axios";
import { describe, expect, it } from "vitest";
import { retryTransientNetworkFailures } from "./QueryClient";
import { isTransientNetworkError } from "./transientNetworkError";
function axiosErrorWithStatus(status: number): AxiosError {
const error = new AxiosError("request failed");
error.response = {
status,
statusText: "",
data: null,
headers: {},
config: { headers: new AxiosHeaders() },
};
return error;
}
describe("isTransientNetworkError", () => {
it("matches axios errors without a response (transport failures)", () => {
expect(isTransientNetworkError(new AxiosError("Network Error"))).toBe(true);
expect(
isTransientNetworkError(
new AxiosError("Network Error", AxiosError.ERR_NETWORK),
),
).toBe(true);
});
it("excludes cancellations even though they lack a response", () => {
expect(isTransientNetworkError(new CanceledError())).toBe(false);
expect(
isTransientNetworkError(
new AxiosError("canceled", AxiosError.ERR_CANCELED),
),
).toBe(false);
});
it("excludes HTTP error responses", () => {
expect(isTransientNetworkError(axiosErrorWithStatus(500))).toBe(false);
expect(isTransientNetworkError(axiosErrorWithStatus(404))).toBe(false);
});
it("excludes non-axios errors", () => {
expect(isTransientNetworkError(new Error("boom"))).toBe(false);
expect(isTransientNetworkError("nope")).toBe(false);
expect(isTransientNetworkError(undefined)).toBe(false);
});
});
describe("retryTransientNetworkFailures", () => {
it("retries transport failures up to the cap", () => {
const networkError = new AxiosError(
"Network Error",
AxiosError.ERR_NETWORK,
);
expect(retryTransientNetworkFailures(0, networkError)).toBe(true);
expect(retryTransientNetworkFailures(1, networkError)).toBe(true);
expect(retryTransientNetworkFailures(2, networkError)).toBe(false);
});
it("does not retry HTTP errors or cancellations", () => {
expect(retryTransientNetworkFailures(0, axiosErrorWithStatus(500))).toBe(
false,
);
expect(retryTransientNetworkFailures(0, new CanceledError())).toBe(false);
});
});

View file

@ -0,0 +1,13 @@
import { AxiosError, isAxiosError, isCancel } from "axios";
function isTransientNetworkError(error: unknown): boolean {
if (!isAxiosError(error)) {
return false;
}
if (isCancel(error) || error.code === AxiosError.ERR_CANCELED) {
return false;
}
return error.response === undefined;
}
export { isTransientNetworkError };

View file

@ -31,6 +31,7 @@ export type ArtifactType = (typeof ArtifactType)[keyof typeof ArtifactType];
export const TriggerType = {
Manual: "manual",
Mcp: "mcp",
Api: "api",
Scheduled: "scheduled",
} as const;
@ -289,6 +290,47 @@ export type ClearOrganizationAuthTokenResponse = {
success: boolean;
};
export type CustomLLMProvider = "openai_compatible" | "ollama" | "openrouter";
export type CustomLLMConfig = {
display_name: string;
provider: CustomLLMProvider;
model_name: string;
api_base?: string | null;
api_key?: string | null;
api_version?: string | null;
supports_vision: boolean;
add_assistant_prefix: boolean;
max_completion_tokens?: number | null;
temperature?: number | null;
reasoning_effort?: string | null;
};
export type CustomLLM = {
id: string;
organization_id: string;
config: CustomLLMConfig;
created_at: string;
modified_at: string;
valid: boolean;
};
export type CustomLLMListResponse = {
custom_llms: Array<CustomLLM>;
};
export type CustomLLMResponse = {
custom_llm: CustomLLM;
};
export type CustomLLMCreateRequest = {
config: CustomLLMConfig;
};
export type CustomLLMUpdateRequest = {
config: CustomLLMConfig;
};
export interface AzureClientSecretCredential {
tenant_id: string;
client_id: string;
@ -654,6 +696,7 @@ export type TaskRunListItem = {
workflow_permanent_id: string | null;
workflow_deleted: boolean;
script_run: boolean;
trigger_type?: TriggerType | null;
searchable_text: string | null;
};
@ -679,6 +722,7 @@ export type WorkflowRunStatusApiResponse = {
webhook_failure_reason: string | null;
downloaded_file_urls: Array<string> | null;
downloaded_files: Array<DownloadedFileInfo> | null;
errors: Array<Record<string, unknown>> | null;
total_steps: number | null;
total_cost: number | null;
credits_used: number;
@ -716,6 +760,7 @@ export type WorkflowRunStatusApiResponseWithWorkflow = {
webhook_failure_reason: string | null;
downloaded_file_urls: Array<string> | null;
downloaded_files: Array<DownloadedFileInfo> | null;
errors: Array<Record<string, unknown>> | null;
total_steps: number | null;
total_cost: number | null;
credits_used: number;
@ -799,6 +844,8 @@ export type BrowserProfileApiResponse = {
source_browser_type: string | null;
proxy_location?: ProxyLocation | null;
proxy_session_id?: string | null;
is_managed?: boolean;
workflow_permanent_id?: string | null;
created_at: string;
modified_at: string;
deleted_at: string | null;

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -1,4 +1,4 @@
import { type ReactElement } from "react";
import { type ReactElement, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
@ -9,6 +9,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { toast } from "@/components/ui/use-toast";
import { copyText } from "@/util/copyText";
import {
@ -25,6 +30,8 @@ type ApiWebhookActionsMenuProps = {
// Single element forwarded to the trigger via Radix `asChild`; when set,
// `size` is unused. Defaults to the solid secondary button (legacy pages).
trigger?: ReactElement;
// Radix tooltip on the trigger; the host must provide a TooltipProvider.
triggerTooltip?: ReactNode;
};
export function ApiWebhookActionsMenu({
@ -33,16 +40,27 @@ export function ApiWebhookActionsMenu({
onTestWebhook,
size = "default",
trigger,
triggerTooltip,
}: ApiWebhookActionsMenuProps) {
const menuTrigger = (
<DropdownMenuTrigger asChild>
{trigger ?? (
<Button type="button" variant="secondary" size={size}>
API & Webhooks
</Button>
)}
</DropdownMenuTrigger>
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{trigger ?? (
<Button type="button" variant="secondary" size={size}>
API & Webhooks
</Button>
)}
</DropdownMenuTrigger>
{triggerTooltip ? (
<Tooltip>
<TooltipTrigger asChild>{menuTrigger}</TooltipTrigger>
<TooltipContent side="bottom">{triggerTooltip}</TooltipContent>
</Tooltip>
) : (
menuTrigger
)}
<DropdownMenuContent>
<DropdownMenuLabel className="py-2 text-base">
Re-run via API

View file

@ -1,3 +1,4 @@
import { StrictMode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => {
clipboardPasteFrom: ReturnType<typeof vi.fn>;
sendKey: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
_framebufferUpdate: () => boolean;
}> = [];
const apiGet = vi.fn(async () => ({
data: {
@ -33,6 +35,7 @@ const mocks = vi.hoisted(() => {
clipboardPasteFrom = vi.fn();
sendKey = vi.fn();
disconnect = vi.fn();
_framebufferUpdate = vi.fn(() => true);
private listeners: Record<string, RfbListener[]> = {};
@ -78,10 +81,14 @@ const mocks = vi.hoisted(() => {
const recordingStore = {
add: vi.fn(),
addScreenshot: vi.fn(),
applyInterpretationUpdate: vi.fn(),
compressedChunks: [],
draftEditDepth: 0,
getEventCount: vi.fn(() => 0),
getSecondsRecording: vi.fn(() => 0),
isRecording: false,
manualCapturePaused: false,
pendingEvents: [],
reset: vi.fn(),
setIsRecording: vi.fn(),
@ -118,11 +125,24 @@ vi.mock("@/store/SettingsStore", () => ({
useSettingsStore: () => mocks.settingsStore,
}));
vi.mock("@/store/useRecordingStore", () => ({
useRecordingStore: () => mocks.recordingStore,
}));
vi.mock("@/store/useRecordingStore", () => {
// Honor the selector: BrowserStream reads slices (e.g. state.isRecording) via
// useRecordingStore(selector). Ignoring the selector and returning the whole
// store makes primitive selectors yield the store object instead of the field
// value — truthy where a boolean was expected — spuriously rendering the
// recording UI.
const useRecordingStore = (
selector?: (state: typeof mocks.recordingStore) => unknown,
) => (selector ? selector(mocks.recordingStore) : mocks.recordingStore);
// Also read imperatively (reset/addScreenshot/etc.) via getState().
useRecordingStore.getState = () => mocks.recordingStore;
return {
useRecordingStore,
countVisibleDraftSteps: (steps: Array<unknown> = []) => steps.length,
};
});
function renderBrowserStream() {
function renderBrowserStream(props: { onActivity?: () => void } = {}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@ -137,11 +157,32 @@ function renderBrowserStream() {
browserSessionId="pbs_test"
interactive={false}
showControlButtons={true}
onActivity={props.onActivity}
/>
</QueryClientProvider>,
);
}
function renderWithRecordingReset(
resetRecordingOnUnmount: boolean | undefined,
{ strict = false }: { strict?: boolean } = {},
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const tree = (
<QueryClientProvider client={queryClient}>
<BrowserStream
browserSessionId="pbs_test"
interactive={false}
showControlButtons={true}
resetRecordingOnUnmount={resetRecordingOnUnmount}
/>
</QueryClientProvider>
);
return render(strict ? <StrictMode>{tree}</StrictMode> : tree);
}
describe("BrowserStream", () => {
beforeEach(() => {
Object.defineProperty(globalThis, "WebSocket", {
@ -191,4 +232,58 @@ describe("BrowserStream", () => {
expect(mocks.rfbInstances[0]?.sendKey).toHaveBeenCalledTimes(4);
});
});
it("notifies activity after a VNC framebuffer update completes", async () => {
const onActivity = vi.fn();
renderBrowserStream({ onActivity });
await waitFor(() => {
expect(mocks.rfbInstances).toHaveLength(1);
});
mocks.rfbInstances[0]!._framebufferUpdate();
expect(onActivity).toHaveBeenCalledTimes(1);
});
describe("recording reset lifecycle", () => {
it("resets the recording store on unmount by default", async () => {
const { unmount } = renderWithRecordingReset(undefined);
await waitFor(() => expect(mocks.rfbInstances).toHaveLength(1));
expect(mocks.recordingStore.reset).not.toHaveBeenCalled();
unmount();
expect(mocks.recordingStore.reset).toHaveBeenCalledTimes(1);
});
it("does not reset the recording store on unmount when opted out", async () => {
const { unmount } = renderWithRecordingReset(false);
await waitFor(() => expect(mocks.rfbInstances).toHaveLength(1));
unmount();
expect(mocks.recordingStore.reset).not.toHaveBeenCalled();
});
// Pins that StrictMode's transient unmount really fires the cleanup in this
// environment, so the opt-out test below cannot pass vacuously.
it("runs the unmount cleanup on a StrictMode double-mount by default", async () => {
renderWithRecordingReset(undefined, { strict: true });
await waitFor(() =>
expect(mocks.recordingStore.reset).toHaveBeenCalledTimes(1),
);
});
// The local repro: StrictMode remounts the fresh VNC stream (mount -> unmount
// -> mount) the instant recording starts. The transient unmount must not
// clear the recording that just began.
it("survives a StrictMode double-mount when opted out", async () => {
renderWithRecordingReset(false, { strict: true });
await waitFor(() =>
expect(mocks.rfbInstances.length).toBeGreaterThanOrEqual(1),
);
expect(mocks.recordingStore.reset).not.toHaveBeenCalled();
});
});
});

View file

@ -5,8 +5,9 @@ import _RFB, { type RfbEvent } from "@novnc/novnc/lib/rfb.js";
type RFB = _RFB;
const RFB = (_RFB as typeof _RFB & { default?: typeof _RFB }).default ?? _RFB;
import { ExitIcon, HandIcon, InfoCircledIcon } from "@radix-ui/react-icons";
import { useEffect, useState, useRef, useCallback } from "react";
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { useShallow } from "zustand/react/shallow";
import { getClient } from "@/api/AxiosClient";
import {
@ -16,29 +17,24 @@ import {
} from "@/api/types";
import { Tip } from "@/components/Tip";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useRecordingElapsedSeconds } from "@/hooks/useRecordingElapsedSeconds";
import { statusIsNotFinalized } from "@/routes/tasks/types";
import { buildOptimisticStep } from "@/routes/workflows/editor/recording/optimisticSteps";
import { useClientIdStore } from "@/store/useClientIdStore";
import {
useRecordingStore,
countVisibleDraftSteps,
type ExfiltratedEventConsoleParams,
type MessageInExfiltratedEvent,
type RecordingInterpretationUpdate,
} from "@/store/useRecordingStore";
import { useSettingsStore } from "@/store/SettingsStore";
import { wssBaseUrl, newWssBaseUrl, getCredentialParam } from "@/util/env";
import { copyText } from "@/util/copyText";
import { formatRecordingClock } from "@/util/recordingClock";
import { cn } from "@/util/utils";
import { captureRecordBrowser } from "@/util/recordBrowserTelemetry";
import {
StreamStatusPanel,
type StreamDiagnostic,
@ -66,6 +62,14 @@ interface BrowserSession {
interface CommandBeginExfiltration {
kind: "begin-exfiltration";
workflow_permanent_id?: string;
live_interpretation_enabled?: boolean;
// Declares that this client understands delta interpretation updates, so the
// backend may send changed_steps instead of full snapshots.
supports_interpretation_deltas?: boolean;
// Per-recording id: stable across reconnects, new per recording, so the
// backend reuses the session on reconnect but starts fresh on a new recording.
recording_attempt_id?: string;
}
interface CommandCedeControl {
@ -80,17 +84,33 @@ interface CommandTakeControl {
kind: "take-control";
}
interface CommandRecordingCapturePause {
kind: "recording-capture-pause";
}
interface CommandRecordingCaptureResume {
kind: "recording-capture-resume";
}
interface CommandRecordingRearmCapture {
kind: "recording-rearm-capture";
}
// a "Command" is an fire-n-forget out-message - it does not require a response
type Command =
| CommandBeginExfiltration
| CommandCedeControl
| CommandEndExfiltration
| CommandRecordingCapturePause
| CommandRecordingCaptureResume
| CommandRecordingRearmCapture
| CommandTakeControl;
const messageInKinds = [
"ask-for-clipboard",
"copied-text",
"exfiltrated-event",
"recording-interpretation-update",
] as const;
type MessageInKind = (typeof messageInKinds)[number];
@ -104,10 +124,15 @@ interface MessageInCopiedText {
text: string;
}
interface MessageInRecordingInterpretationUpdate extends RecordingInterpretationUpdate {
kind: "recording-interpretation-update";
}
type MessageIn =
| MessageInCopiedText
| MessageInAskForClipboard
| MessageInExfiltratedEvent;
| MessageInExfiltratedEvent
| MessageInRecordingInterpretationUpdate;
interface MessageOutAskForClipboardResponse {
kind: "ask-for-clipboard-response";
@ -121,6 +146,9 @@ type Props = {
exfiltrate?: boolean;
interactive?: boolean;
showControlButtons?: boolean;
// Whether unmounting clears the recording store. The studio passes false: it
// remounts this component across CDP<->VNC swaps without the session ending.
resetRecordingOnUnmount?: boolean;
task?: {
run: TaskApiResponse;
};
@ -130,22 +158,110 @@ type Props = {
resizeTrigger?: number;
isVisible?: boolean;
isExecuting?: boolean;
// Hide the REC pill overlay when the recording panel is visible beside the
// stream (its header already shows the timer + step count).
hideRecordingIndicator?: boolean;
onReadyChange?: (isReady: boolean, browserSessionId: string | null) => void;
onActivity?: () => void;
// --
onClose?: () => void;
};
type RfbWithFrameUpdates = RFB & {
_framebufferUpdate?: () => boolean;
};
/** VNC encode settings: favor fast frames when the user is driving the browser. */
function applyVncStreamProfile(
rfb: RFB,
profile: "interactive" | "passive",
): void {
if (profile === "interactive") {
// Low CPU per frame beats max zlib compression for click/type latency.
rfb.compressionLevel = 1;
rfb.qualityLevel = 7;
return;
}
rfb.compressionLevel = 2;
rfb.qualityLevel = 6;
}
function RecordingPill() {
const {
finishRequested,
manualCapturePaused,
draftSteps,
deletedStepIds,
exposedEventCount,
optimisticStepCount,
interpretationEnabled,
} = useRecordingStore(
useShallow((state) => ({
finishRequested: state.finishRequested,
manualCapturePaused: state.manualCapturePaused,
draftSteps: state.draftSteps,
deletedStepIds: state.deletedStepIds,
exposedEventCount: state.exposedEventCount,
optimisticStepCount: state.optimisticSteps.length,
interpretationEnabled: state.workflowPermanentId !== null,
})),
);
const interpretedStepCount = useMemo(
() => countVisibleDraftSteps(draftSteps, deletedStepIds),
[draftSteps, deletedStepIds],
);
const elapsedSeconds = useRecordingElapsedSeconds();
// Show interpreted + optimistic steps whenever interpretation is enabled, not
// just after the first snapshot arrives — otherwise the first step waits a
// backend round-trip even though the optimistic placeholder is already local.
const count = interpretationEnabled
? interpretedStepCount + optimisticStepCount
: exposedEventCount;
const paused = manualCapturePaused && !finishRequested;
return (
<div
className={cn(
"inline-flex h-6 items-center gap-2 rounded-full border px-3 text-xs font-semibold tabular-nums",
paused
? "border-amber-500/50 bg-amber-950 text-amber-200"
: "border-red-500/50 bg-red-950 text-red-200",
)}
>
<span
className={cn("h-2 w-2 rounded-full", {
"bg-amber-500": paused,
"bg-red-500": !paused,
"animate-pulse": !finishRequested && !paused,
"opacity-50": finishRequested,
})}
/>
{finishRequested ? "FINISHING" : paused ? "PAUSED" : "REC"}{" "}
{formatRecordingClock(elapsedSeconds)}
<span className={paused ? "text-amber-400/80" : "text-red-400/80"}>
·
</span>
{count}
</div>
);
}
function BrowserStream({
browserSessionId = undefined,
exfiltrate = false,
interactive = true,
showControlButtons = undefined,
resetRecordingOnUnmount = true,
task = undefined,
workflow = undefined,
resizeTrigger,
isVisible = true,
isExecuting = false,
hideRecordingIndicator = false,
onReadyChange,
onActivity,
// --
onClose,
}: Props) {
@ -226,6 +342,7 @@ function BrowserStream({
setCanvasContainer(node);
}, []);
const rfbRef = useRef<RFB | null>(null);
const onActivityRef = useRef(onActivity);
const userCanSendVncInputRef = useRef(false);
const observerRef = useRef<MutationObserver | null>(null);
const messageReconnectAttemptsRef = useRef(0);
@ -233,7 +350,7 @@ function BrowserStream({
null,
);
const clientId = useClientIdStore((state) => state.clientId);
const recordingStore = useRecordingStore();
const isRecording = useRecordingStore((state) => state.isRecording);
const settingsStore = useSettingsStore();
const credentialGetter = useCredentialGetter();
const isBrowserSessionAvailable =
@ -241,6 +358,10 @@ function BrowserStream({
const isBrowserSessionBackendReady =
entity !== "browserSession" || isBrowserSessionStarted;
useEffect(() => {
onActivityRef.current = onActivity;
}, [onActivity]);
useEffect(() => {
setIsBrowserSessionStarted(false);
setIsReady(false);
@ -364,6 +485,11 @@ function BrowserStream({
};
}, []);
// The low-latency encode profile is scoped to recording: that's where frame
// lag directly delays draft feedback. Other interactive live-browser streams
// keep the default profile to avoid a broad bandwidth/CPU bump.
const vncInteractive = exfiltrate;
// vnc socket
useEffect(
() => {
@ -431,6 +557,23 @@ function BrowserStream({
const rfb = new RFB(canvas, vncUrl);
rfb.scaleViewport = true;
applyVncStreamProfile(rfb, vncInteractive ? "interactive" : "passive");
const frameUpdateRfb = rfb as RfbWithFrameUpdates;
// noVNC does not expose a public framebuffer-update event in 1.5.x.
// Hook the internal method defensively so activity tracking degrades
// to no-op if the private API changes.
const originalFrameUpdate =
frameUpdateRfb._framebufferUpdate?.bind(rfb);
if (originalFrameUpdate) {
frameUpdateRfb._framebufferUpdate = () => {
const didCompleteFrameUpdate = originalFrameUpdate();
if (didCompleteFrameUpdate) {
onActivityRef.current?.();
}
return didCompleteFrameUpdate;
};
}
rfbRef.current = rfb;
@ -500,6 +643,17 @@ function BrowserStream({
],
);
// Re-apply encode profile when recording starts without tearing down the socket.
useEffect(() => {
if (!rfbRef.current) {
return;
}
applyVncStreamProfile(
rfbRef.current,
vncInteractive ? "interactive" : "passive",
);
}, [vncInteractive]);
useEffect(() => {
if (!showStream || !canvasContainer || !runId) {
return;
@ -674,6 +828,13 @@ function BrowserStream({
}
}, [task, workflow]);
const { workflowPermanentId, recordingAttemptId } = useRecordingStore(
useShallow((state) => ({
workflowPermanentId: state.workflowPermanentId,
recordingAttemptId: state.recordingAttemptId,
})),
);
// effect for exfiltration
useEffect(() => {
const sendCommand = (command: Command) => {
@ -684,10 +845,56 @@ function BrowserStream({
messageSocket.send(JSON.stringify(command));
};
sendCommand({
kind: exfiltrate ? "begin-exfiltration" : "end-exfiltration",
});
}, [exfiltrate, messageSocket]);
if (exfiltrate) {
// Including a workflow id turns on backend live interpretation: the
// server streams recording-interpretation-update draft step snapshots
// back over this same socket while the user records.
sendCommand({
kind: "begin-exfiltration",
workflow_permanent_id: workflowPermanentId ?? undefined,
live_interpretation_enabled: Boolean(workflowPermanentId),
// Client capability: the backend only emits per-step delta updates
// (changed_steps upserted by step_id) to clients that declare support;
// otherwise it falls back to full snapshots.
supports_interpretation_deltas: true,
// Stable across reconnects of this recording; a new recording mints a
// new id so the backend starts a fresh interpretation session.
recording_attempt_id: recordingAttemptId ?? undefined,
});
} else {
sendCommand({ kind: "end-exfiltration" });
}
}, [exfiltrate, messageSocket, workflowPermanentId, recordingAttemptId]);
const manualCapturePaused = useRecordingStore(
(state) => state.manualCapturePaused,
);
const draftEditDepth = useRecordingStore((state) => state.draftEditDepth);
const capturePaused = manualCapturePaused || draftEditDepth > 0;
const previousCapturePausedRef = useRef(false);
// Pause exfiltration + live interpretation while the operator edits drafts
// or explicitly pauses capture.
useEffect(() => {
if (!exfiltrate || !messageSocket) {
// Backend pause state is per exfiltration session, so start the next
// session's edge detection from "not paused". A recording that ended
// while paused would otherwise fire a spurious resume on the next start;
// and if capture IS paused on a mid-recording reconnect, this re-sends
// the pause to the new socket (idempotent) instead of assuming it.
previousCapturePausedRef.current = false;
return;
}
const wasPaused = previousCapturePausedRef.current;
if (!wasPaused && capturePaused) {
messageSocket.send(JSON.stringify({ kind: "recording-capture-pause" }));
} else if (wasPaused && !capturePaused) {
messageSocket.send(JSON.stringify({ kind: "recording-capture-resume" }));
}
previousCapturePausedRef.current = capturePaused;
}, [capturePaused, exfiltrate, messageSocket]);
useEffect(() => {
if (!interactive) {
@ -695,6 +902,15 @@ function BrowserStream({
}
}, [interactive]);
// When control can no longer be offered (buttons hidden and not inherently
// interactive), a prior grab must be released or its input keeps flowing.
// Recording is exempt: it holds take-control for exfiltration.
useEffect(() => {
if (!interactive && !showControlButtons && !isRecording) {
setUserIsControlling(false);
}
}, [interactive, showControlButtons, isRecording]);
const theUserIsControlling =
userIsControlling || (interactive && !showControlButtons);
@ -721,18 +937,26 @@ function BrowserStream({
};
}, [canvasContainer]);
// effect to ensure the recordingStore is reset when the component unmounts
// Read the flag through a ref so the unmount cleanup stays mount-scoped: a
// StrictMode double-mount or transport swap must not cancel a live recording.
const resetRecordingOnUnmountRef = useRef(resetRecordingOnUnmount);
resetRecordingOnUnmountRef.current = resetRecordingOnUnmount;
// By default an unmount means the user abandoned the session, and the reset
// keeps stale isRecording from leaking into the next mounted workflow as a
// stuck recording panel. Surfaces that remount the stream while the session
// lives on (transport swaps, per-run streams) opt out and reset at the
// session level instead.
useEffect(() => {
return () => {
recordingStore.reset();
if (resetRecordingOnUnmountRef.current) {
useRecordingStore.getState().reset();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// effect to ensure 'take-control' is sent on the rising edge of
// recordingStore.isRecording
// effect to ensure 'take-control' is sent on the rising edge of isRecording
useEffect(() => {
if (!recordingStore.isRecording) {
if (!isRecording) {
return;
}
@ -750,7 +974,7 @@ function BrowserStream({
sendCommand({ kind: "take-control" });
setUserIsControlling(true);
}, [recordingStore.isRecording, isMessageConnected, messageSocket]);
}, [isRecording, isMessageConnected, messageSocket]);
/**
* TODO(jdo): could use zod or smth similar
@ -815,6 +1039,38 @@ function BrowserStream({
}
break;
}
case "recording-interpretation-update": {
// steps is optional: a delta update carries changed_steps instead. Only
// session_revision is required to accept the message.
if (
"session_revision" in data &&
typeof data.session_revision === "number"
) {
const update = data as MessageInRecordingInterpretationUpdate;
return {
kind: "recording-interpretation-update",
interpretation_session_id:
typeof update.interpretation_session_id === "string"
? update.interpretation_session_id
: "",
session_revision: update.session_revision,
steps: Array.isArray(update.steps) ? update.steps : [],
changed_steps: Array.isArray(update.changed_steps)
? update.changed_steps
: [],
// Absent/true => full snapshot (legacy). Only false triggers delta merge.
is_snapshot:
typeof update.is_snapshot === "boolean"
? update.is_snapshot
: true,
pending:
typeof update.pending === "boolean" ? update.pending : false,
finalized:
typeof update.finalized === "boolean" ? update.finalized : false,
};
}
break;
}
default: {
const _exhaustive: never = kind;
return _exhaustive;
@ -822,6 +1078,34 @@ function BrowserStream({
}
};
// Best-effort frame grab from the VNC canvas at the moment of a recorded
// click, so the live feed can show a zoomed shot at the action point.
const captureRecordingScreenshot = (
params: ExfiltratedEventConsoleParams,
) => {
const schedule =
typeof requestIdleCallback === "function"
? (fn: () => void) => requestIdleCallback(fn, { timeout: 750 })
: (fn: () => void) => window.setTimeout(fn, 0);
schedule(() => {
try {
const canvas = canvasContainer?.querySelector("canvas");
if (!canvas) {
return;
}
useRecordingStore.getState().addScreenshot({
timestampMs: params.timestamp,
dataUrl: canvas.toDataURL("image/jpeg", 0.5),
xp: params.mousePosition.xp,
yp: params.mousePosition.yp,
});
} catch {
// toDataURL can throw on a tainted/headless canvas; shots are optional
}
});
};
const handleMessage = (data: unknown, ws: WebSocket | null) => {
const message = getMessage(data);
@ -902,7 +1186,52 @@ function BrowserStream({
break;
}
case "exfiltrated-event": {
recordingStore.add(message);
// Read store state fresh: this handler is attached once per socket
// and would otherwise see a stale isRecording.
const store = useRecordingStore.getState();
if (!store.isRecording && !store.finishRequested) {
break;
}
if (store.isCapturePaused()) {
break;
}
if (
store.isRecording &&
message.source === "console" &&
message.params.type === "click"
) {
captureRecordingScreenshot(message.params);
}
if (
store.isRecording &&
!store.finishRequested &&
message.source === "cdp" &&
(message.event_name === "nav:frame_navigated" ||
message.event_name === "nav:navigated_within_document")
) {
if (ws) {
ws.send(JSON.stringify({ kind: "recording-rearm-capture" }));
}
}
if (store.isRecording && !store.finishRequested) {
const optimistic = buildOptimisticStep(message);
if (optimistic) {
store.addOptimisticStep(optimistic);
}
}
store.add(message);
break;
}
case "recording-interpretation-update": {
useRecordingStore.getState().applyInterpretationUpdate({
interpretation_session_id: message.interpretation_session_id,
session_revision: message.session_revision,
steps: message.steps,
changed_steps: message.changed_steps,
is_snapshot: message.is_snapshot,
pending: message.pending,
finalized: message.finalized,
});
break;
}
default: {
@ -998,84 +1327,25 @@ function BrowserStream({
)}
</div>
)}
{recordingStore.isRecording && (
<>
<div className="pointer-events-none absolute flex aspect-video w-full items-center justify-center rounded-xl p-2 outline outline-8 outline-offset-[-2px] outline-red-500 animate-in fade-in">
{isRecording && (
<div className="pointer-events-none absolute flex aspect-video w-full items-center justify-center rounded-xl p-2 outline outline-8 outline-offset-[-2px] outline-red-500 animate-in fade-in">
{/* The pill duplicates the recording panel's header (timer + step
count), so it's hidden while the panel is visible alongside. */}
{!hideRecordingIndicator && (
<div className="relative h-full w-full">
<div className="pointer-events-auto absolute top-[-3rem] flex w-full items-center justify-start gap-2 text-red-500">
<div className="truncate">Browser is recording</div>
<Tip content="To finish and turn your actions into blocks, click the stop icon.">
<div className="cursor-pointer">
<div className="pointer-events-auto absolute top-[-3rem] flex w-full items-center justify-start gap-2">
<RecordingPill />
<Tip content="Your actions appear as blocks in the recording panel. Finish with Done, or use the trash icon to discard.">
<div className="cursor-pointer text-red-500">
<InfoCircledIcon />
</div>
</Tip>
<Dialog>
<DialogTrigger asChild>
<Button
className="ml-auto cursor-pointer"
size="sm"
variant="destructive"
style={{
marginTop: "-0.5rem",
}}
onClick={(e) => {
const hasEvents =
recordingStore.pendingEvents.length > 0 ||
recordingStore.compressedChunks.length > 0;
if (!hasEvents) {
e.preventDefault();
captureRecordBrowser("record_browser.cancelled", {
event_count_at_cancel:
recordingStore.getEventCount(),
seconds_recording:
recordingStore.getSecondsRecording(),
});
recordingStore.setIsRecording(false);
recordingStore.reset();
}
}}
>
cancel
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel recording?</DialogTitle>
<DialogDescription>
You have recorded events that will be lost if you
cancel. Are you sure you want to cancel the recording?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Keep recording</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="destructive"
onClick={() => {
captureRecordBrowser("record_browser.cancelled", {
event_count_at_cancel:
recordingStore.getEventCount(),
seconds_recording:
recordingStore.getSecondsRecording(),
});
recordingStore.setIsRecording(false);
recordingStore.reset();
}}
>
Cancel recording
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</div>
</>
)}
</div>
)}
{isExecuting && !recordingStore.isRecording && (
{isExecuting && !isRecording && (
<div className="pointer-events-none absolute flex aspect-video w-full animate-glow items-center justify-center rounded-xl p-2 outline outline-8 outline-offset-[-2px] outline-yellow-500">
<div className="relative h-full w-full">
<div className="pointer-events-auto absolute top-[-3rem] flex w-full items-center justify-start gap-2 text-yellow-500">

View file

@ -0,0 +1,596 @@
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
CheckIcon,
Cross2Icon,
EyeClosedIcon,
EyeOpenIcon,
Pencil1Icon,
PlusIcon,
TrashIcon,
} from "@radix-ui/react-icons";
import { CustomLLM, CustomLLMConfig, CustomLLMProvider } from "@/api/types";
import { useCustomLLMs } from "@/hooks/useCustomLLMs";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
const providerLabels: Record<CustomLLMProvider, string> = {
openai_compatible: "OpenAI compatible",
ollama: "Ollama",
openrouter: "OpenRouter",
};
const optionalString = z.preprocess(
(value) => (typeof value === "string" ? value.trim() || null : value),
z.string().nullable().optional(),
);
const optionalNumber = (schema: z.ZodNumber) =>
z.preprocess((value) => {
if (value === "" || value === null || value === undefined) {
return null;
}
return Number(value);
}, schema.nullable().optional());
const configSchema = z
.object({
display_name: z.string().trim().min(1, "Name is required"),
provider: z.enum(["openai_compatible", "ollama", "openrouter"]),
model_name: z.string().trim().min(1, "Model ID is required"),
api_base: optionalString,
api_key: optionalString,
api_version: optionalString,
supports_vision: z.boolean(),
add_assistant_prefix: z.boolean(),
max_completion_tokens: optionalNumber(
z
.number()
.int("Max tokens must be a whole number")
.min(1, "Max tokens must be at least 1")
.max(1_000_000, "Max tokens must be 1,000,000 or less"),
),
temperature: optionalNumber(
z
.number()
.min(0, "Temperature must be at least 0")
.max(2, "Temperature must be 2 or less"),
),
reasoning_effort: optionalString,
})
.superRefine((value, ctx) => {
if (value.provider === "openai_compatible" && !value.api_base) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["api_base"],
message: "API base is required",
});
}
if (
(value.provider === "openai_compatible" ||
value.provider === "openrouter") &&
!value.api_key
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["api_key"],
message: "API key is required",
});
}
});
const formSchema = z.object({ config: configSchema });
type FormData = z.infer<typeof formSchema>;
const providerDefaults: Record<CustomLLMProvider, Partial<CustomLLMConfig>> = {
openai_compatible: {
api_base: "",
api_key: "",
api_version: "",
supports_vision: true,
add_assistant_prefix: false,
},
ollama: {
api_base: "http://localhost:11434",
api_key: "",
api_version: "",
supports_vision: false,
add_assistant_prefix: false,
},
openrouter: {
api_base: "https://openrouter.ai/api/v1",
api_key: "",
api_version: "",
supports_vision: true,
add_assistant_prefix: false,
},
};
function emptyFormValues(provider: CustomLLMProvider = "openai_compatible") {
return {
config: {
display_name: "",
provider,
model_name: "",
api_base: providerDefaults[provider].api_base ?? "",
api_key: providerDefaults[provider].api_key ?? "",
api_version: providerDefaults[provider].api_version ?? "",
supports_vision: providerDefaults[provider].supports_vision ?? true,
add_assistant_prefix:
providerDefaults[provider].add_assistant_prefix ?? false,
max_completion_tokens: null,
temperature: null,
reasoning_effort: "",
},
};
}
function valuesForCustomLLM(customLLM: CustomLLM): FormData {
return {
config: {
...customLLM.config,
api_base: customLLM.config.api_base ?? "",
api_key: customLLM.config.api_key ?? "",
api_version: customLLM.config.api_version ?? "",
max_completion_tokens: customLLM.config.max_completion_tokens ?? null,
temperature: customLLM.config.temperature ?? null,
reasoning_effort: customLLM.config.reasoning_effort ?? "",
},
};
}
function requestFromValues(values: FormData): { config: CustomLLMConfig } {
return {
config: {
...values.config,
api_base: values.config.api_base || null,
api_key: values.config.api_key || null,
api_version: values.config.api_version || null,
max_completion_tokens: values.config.max_completion_tokens || null,
temperature:
values.config.temperature === undefined
? null
: values.config.temperature,
reasoning_effort: values.config.reasoning_effort || null,
},
};
}
export function CustomLLMConfigForm() {
const [editing, setEditing] = useState<CustomLLM | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const {
customLLMs,
isLoading,
createCustomLLM,
updateCustomLLM,
deleteCustomLLM,
isCreating,
isUpdating,
isDeleting,
} = useCustomLLMs();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: emptyFormValues(),
});
const provider = form.watch("config.provider");
const isMutating = isCreating || isUpdating || isDeleting;
useEffect(() => {
if (!editing) {
return;
}
form.reset(valuesForCustomLLM(editing));
}, [editing, form]);
const resetForm = () => {
setEditing(null);
setShowApiKey(false);
form.reset(emptyFormValues());
};
const onSubmit = (values: FormData) => {
const request = requestFromValues(values);
if (editing) {
updateCustomLLM({ id: editing.id, request }, { onSuccess: resetForm });
return;
}
createCustomLLM(request, { onSuccess: resetForm });
};
const onProviderChange = (nextProvider: CustomLLMProvider) => {
form.setValue("config.provider", nextProvider);
form.setValue(
"config.api_base",
providerDefaults[nextProvider].api_base ?? "",
);
form.setValue(
"config.api_key",
providerDefaults[nextProvider].api_key ?? "",
);
form.setValue(
"config.api_version",
providerDefaults[nextProvider].api_version ?? "",
);
form.setValue(
"config.supports_vision",
providerDefaults[nextProvider].supports_vision ?? true,
);
form.setValue(
"config.add_assistant_prefix",
providerDefaults[nextProvider].add_assistant_prefix ?? false,
);
};
return (
<div className="space-y-6">
{customLLMs.length > 0 && (
<div className="space-y-2">
{customLLMs.map((customLLM) => (
<div
key={customLLM.id}
className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{customLLM.config.display_name}
</span>
<span className="rounded border px-2 py-0.5 text-xs text-muted-foreground">
{providerLabels[customLLM.config.provider]}
</span>
</div>
<div className="break-all text-xs text-muted-foreground">
{customLLM.config.model_name}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={isMutating}
onClick={() => setEditing(customLLM)}
>
<Pencil1Icon className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
type="button"
size="sm"
variant="destructive"
disabled={isMutating}
onClick={() => deleteCustomLLM(customLLM)}
>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
))}
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="config.display_name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Local Llama"
disabled={isLoading || isMutating}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
onProviderChange(value as CustomLLMProvider)
}
disabled={isLoading || isMutating}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(providerLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="config.model_name"
render={({ field }) => (
<FormItem>
<FormLabel>Model ID</FormLabel>
<FormControl>
<Input
{...field}
placeholder={
provider === "openrouter"
? "anthropic/claude-3.5-sonnet"
: provider === "ollama"
? "llama3.1"
: "mistral"
}
disabled={isLoading || isMutating}
/>
</FormControl>
<FormDescription>
{provider === "ollama"
? "Use the Ollama model name. Prefixes like ollama_chat/ are accepted."
: "Use the provider model identifier without the LiteLLM prefix."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="config.api_base"
render={({ field }) => (
<FormItem>
<FormLabel>API Base</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
placeholder="https://api.example.com/v1"
disabled={isLoading || isMutating}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.api_key"
render={({ field }) => (
<FormItem>
<FormLabel>
API Key{provider === "ollama" ? " (optional)" : ""}
</FormLabel>
<div className="relative">
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type={showApiKey ? "text" : "password"}
placeholder={
provider === "ollama" ? "optional" : "sk-..."
}
disabled={isLoading || isMutating}
/>
</FormControl>
<Button
type="button"
variant="ghost"
size="sm"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowApiKey((value) => !value)}
disabled={isLoading || isMutating}
>
{showApiKey ? (
<EyeClosedIcon className="h-4 w-4" />
) : (
<EyeOpenIcon className="h-4 w-4" />
)}
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
{provider === "openai_compatible" && (
<FormField
control={form.control}
name="config.api_version"
render={({ field }) => (
<FormItem>
<FormLabel>API Version</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
placeholder="optional"
disabled={isLoading || isMutating}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="config.max_completion_tokens"
render={({ field }) => (
<FormItem>
<FormLabel>Max Tokens</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type="number"
min={1}
placeholder="default"
disabled={isLoading || isMutating}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.temperature"
render={({ field }) => (
<FormItem>
<FormLabel>Temperature</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type="number"
min={0}
max={2}
step="0.1"
placeholder="default"
disabled={isLoading || isMutating}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.reasoning_effort"
render={({ field }) => (
<FormItem>
<FormLabel>Reasoning Effort</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
placeholder="optional"
disabled={isLoading || isMutating}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-wrap gap-6">
<FormField
control={form.control}
name="config.supports_vision"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isLoading || isMutating}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
Supports vision
</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.add_assistant_prefix"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isLoading || isMutating}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
Assistant prefix
</FormLabel>
</FormItem>
)}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isLoading || isMutating}>
{editing ? (
<CheckIcon className="mr-2 h-4 w-4" />
) : (
<PlusIcon className="mr-2 h-4 w-4" />
)}
{editing
? isUpdating
? "Saving..."
: "Save Custom LLM"
: isCreating
? "Adding..."
: "Add Custom LLM"}
</Button>
{editing && (
<Button
type="button"
variant="outline"
disabled={isLoading || isMutating}
onClick={resetForm}
>
<Cross2Icon className="mr-2 h-4 w-4" />
Cancel
</Button>
)}
</div>
</form>
</Form>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { TriggerType } from "@/api/types";
import {
CalendarIcon,
CubeIcon,
CursorArrowIcon,
LightningBoltIcon,
} from "@radix-ui/react-icons";
@ -18,6 +19,10 @@ const triggerConfig: Record<
icon: <CursorArrowIcon className="size-3.5 text-slate-400" />,
label: "Manual",
},
[TriggerType.Mcp]: {
icon: <CubeIcon className="size-3.5 text-emerald-400" />,
label: "MCP",
},
[TriggerType.Api]: {
icon: <LightningBoltIcon className="size-3.5 text-amber-400" />,
label: "API",

View file

@ -18,6 +18,10 @@ const triggerTypeDropdownItems: Array<TriggerTypeDropdownItem> = [
label: "Manual",
value: TriggerType.Manual,
},
{
label: "MCP",
value: TriggerType.Mcp,
},
{
label: "API",
value: TriggerType.Api,

View file

@ -116,7 +116,7 @@ describe("CopilotCTAStep", () => {
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith("/workflows/wpid_x/studio", {
expect(mockNavigate).toHaveBeenCalledWith("/agents/wpid_x/studio", {
state: { copilotMessage: "fill out my form" },
}),
);
@ -135,7 +135,7 @@ describe("CopilotCTAStep", () => {
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith("/workflows/wpid_x/build", {
expect(mockNavigate).toHaveBeenCalledWith("/agents/wpid_x/build", {
state: { copilotMessage: "fill out my form" },
}),
);

View file

@ -50,7 +50,7 @@ describe("CredentialSetupPrompt", () => {
const cta = screen.getByRole("link", {
name: /set up credentials in the editor/i,
});
expect(cta.getAttribute("href")).toBe("/workflows/wpid_123/studio");
expect(cta.getAttribute("href")).toBe("/agents/wpid_123/studio");
});
it("routes the CTA to /build when the studio preview is off", () => {
@ -60,7 +60,7 @@ describe("CredentialSetupPrompt", () => {
const cta = screen.getByRole("link", {
name: /set up credentials in the editor/i,
});
expect(cta.getAttribute("href")).toBe("/workflows/wpid_123/build");
expect(cta.getAttribute("href")).toBe("/agents/wpid_123/build");
});
it("fires credentialSetupShown once on mount with the block count", () => {

View file

@ -124,7 +124,7 @@ describe("FirstRunRecoveryGuidance", () => {
/>,
);
fireEvent.click(getByTestId("recovery-path-edit_workflow"));
expect(navigateMock).toHaveBeenCalledWith("/workflows/wpid_123/studio");
expect(navigateMock).toHaveBeenCalledWith("/agents/wpid_123/studio");
});
it("navigates to /build when the studio preview is off (AC2 wiring)", () => {
@ -137,7 +137,7 @@ describe("FirstRunRecoveryGuidance", () => {
/>,
);
fireEvent.click(getByTestId("recovery-path-edit_workflow"));
expect(navigateMock).toHaveBeenCalledWith("/workflows/wpid_123/build");
expect(navigateMock).toHaveBeenCalledWith("/agents/wpid_123/build");
});
it("records chosen + opened outcome for an external path (AC3)", () => {

View file

@ -54,7 +54,7 @@ function FirstRunRecoveryGuidance({
if (id === "edit_workflow") {
return workflowPermanentId
? workflowEditorPath(workflowPermanentId, studioEnabled)
: "/workflows";
: "/agents";
}
return "/credentials";
}

View file

@ -55,89 +55,37 @@ function blockBetween(css: string, openSelector: string): string {
return "";
}
const REQUIRED_TOKEN_CASES = [
["--input", ":root"],
["--ring", ":root"],
["--input", ".dark"],
["--ring", ".dark"],
["--success", ":root"],
["--success-foreground", ":root"],
["--warning", ":root"],
["--warning-foreground", ":root"],
["--tertiary", ":root"],
["--tertiary-foreground", ":root"],
["--cta", ":root"],
["--cta-hover", ":root"],
["--cta-foreground", ":root"],
["--success", ".dark"],
["--warning", ".dark"],
["--tertiary", ".dark"],
["--tertiary-foreground", ".dark"],
["--cta", ".dark"],
["--cta-hover", ".dark"],
["--cta-foreground", ".dark"],
] as const;
describe.each(CSS_FILES)("%s defines DS token vars", (file) => {
const css = load(file);
const root = blockBetween(css, ":root");
const dark = blockBetween(css, ".dark");
it("defines --input under :root", () => {
expect(root).toMatch(/--input:\s*[^;]+;/);
});
it("defines --ring under :root", () => {
expect(root).toMatch(/--ring:\s*[^;]+;/);
});
it("defines --input under .dark", () => {
expect(dark).toMatch(/--input:\s*[^;]+;/);
});
it("defines --ring under .dark", () => {
expect(dark).toMatch(/--ring:\s*[^;]+;/);
});
it("defines --success under :root", () => {
expect(root).toMatch(/--success:\s*[^;]+;/);
});
it("defines --success-foreground under :root", () => {
expect(root).toMatch(/--success-foreground:\s*[^;]+;/);
});
it("defines --warning under :root", () => {
expect(root).toMatch(/--warning:\s*[^;]+;/);
});
it("defines --warning-foreground under :root", () => {
expect(root).toMatch(/--warning-foreground:\s*[^;]+;/);
});
it("defines --tertiary under :root", () => {
expect(root).toMatch(/--tertiary:\s*[^;]+;/);
});
it("defines --tertiary-foreground under :root", () => {
expect(root).toMatch(/--tertiary-foreground:\s*[^;]+;/);
});
it("defines --cta under :root", () => {
expect(root).toMatch(/--cta:\s*[^;]+;/);
});
it("defines --cta-hover under :root", () => {
expect(root).toMatch(/--cta-hover:\s*[^;]+;/);
});
it("defines --cta-foreground under :root", () => {
expect(root).toMatch(/--cta-foreground:\s*[^;]+;/);
});
it("defines --success under .dark", () => {
expect(dark).toMatch(/--success:\s*[^;]+;/);
});
it("defines --warning under .dark", () => {
expect(dark).toMatch(/--warning:\s*[^;]+;/);
});
it("defines --tertiary under .dark", () => {
expect(dark).toMatch(/--tertiary:\s*[^;]+;/);
});
it("defines --tertiary-foreground under .dark", () => {
expect(dark).toMatch(/--tertiary-foreground:\s*[^;]+;/);
});
it("defines --cta under .dark", () => {
expect(dark).toMatch(/--cta:\s*[^;]+;/);
});
it("defines --cta-hover under .dark", () => {
expect(dark).toMatch(/--cta-hover:\s*[^;]+;/);
});
it("defines --cta-foreground under .dark", () => {
expect(dark).toMatch(/--cta-foreground:\s*[^;]+;/);
it.each(REQUIRED_TOKEN_CASES)("defines %s under %s", (token, selector) => {
const block = selector === ":root" ? root : dark;
expect(block).toMatch(new RegExp(`${token}:\\s*[^;]+;`));
});
});
@ -153,29 +101,19 @@ describe("shared semantic tokens are consistent across cloud/eval/src", () => {
expect(new Set(values).size).toBe(1);
}
it("light-mode --ring is the same value in all 3 entrypoints", () => {
expectConsistentToken("--ring", ":root");
});
it("dark-mode --ring is the same value in all 3 entrypoints", () => {
expectConsistentToken("--ring", ".dark");
});
it("light-mode --cta is the same value in all 3 entrypoints", () => {
expectConsistentToken("--cta", ":root");
});
it("light-mode --cta-hover is the same value in all 3 entrypoints", () => {
expectConsistentToken("--cta-hover", ":root");
});
it("dark-mode --cta is the same value in all 3 entrypoints", () => {
expectConsistentToken("--cta", ".dark");
});
it("dark-mode --cta-hover is the same value in all 3 entrypoints", () => {
expectConsistentToken("--cta-hover", ".dark");
});
it.each([
["light-mode --ring", "--ring", ":root"],
["dark-mode --ring", "--ring", ".dark"],
["light-mode --cta", "--cta", ":root"],
["light-mode --cta-hover", "--cta-hover", ":root"],
["dark-mode --cta", "--cta", ".dark"],
["dark-mode --cta-hover", "--cta-hover", ".dark"],
] as const)(
"%s is the same value in all 3 entrypoints",
(_, token, selector) => {
expectConsistentToken(token, selector);
},
);
});
// The primary CTA must track the DS --primary token, not an independent
@ -191,27 +129,24 @@ describe("--cta tracks the DS --primary (SKY-10608)", () => {
describe.each(CSS_FILES)("%s", (file) => {
const css = load(file);
it("light-mode --cta equals --primary", () => {
expect(tokenValue(css, ":root", "--cta")).toBe(
tokenValue(css, ":root", "--primary"),
);
});
it("dark-mode --cta equals --primary", () => {
expect(tokenValue(css, ".dark", "--cta")).toBe(
tokenValue(css, ".dark", "--primary"),
);
});
it("light-mode --cta-foreground equals --primary-foreground", () => {
expect(tokenValue(css, ":root", "--cta-foreground")).toBe(
tokenValue(css, ":root", "--primary-foreground"),
);
});
it("dark-mode --cta-foreground equals --primary-foreground", () => {
expect(tokenValue(css, ".dark", "--cta-foreground")).toBe(
tokenValue(css, ".dark", "--primary-foreground"),
it.each([
["light-mode --cta equals --primary", ":root", "--cta", "--primary"],
["dark-mode --cta equals --primary", ".dark", "--cta", "--primary"],
[
"light-mode --cta-foreground equals --primary-foreground",
":root",
"--cta-foreground",
"--primary-foreground",
],
[
"dark-mode --cta-foreground equals --primary-foreground",
".dark",
"--cta-foreground",
"--primary-foreground",
],
] as const)("%s", (_, selector, ctaToken, primaryToken) => {
expect(tokenValue(css, selector, ctaToken)).toBe(
tokenValue(css, selector, primaryToken),
);
});
});

View file

@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const { mockEnabled } = vi.hoisted(() => ({ mockEnabled: vi.fn() }));
vi.mock("posthog-js/react", () => ({
useFeatureFlagEnabled: () => mockEnabled(),
}));
import { useAnalyticsDashboardFlag } from "./useAnalyticsDashboardFlag";
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
describe("useAnalyticsDashboardFlag", () => {
it("passes through the posthog flag value when mock analytics is off", () => {
mockEnabled.mockReturnValue(false);
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
false,
);
mockEnabled.mockReturnValue(undefined);
expect(
renderHook(() => useAnalyticsDashboardFlag()).result.current,
).toBeUndefined();
mockEnabled.mockReturnValue(true);
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
true,
);
});
it("forces true under VITE_MOCK_ANALYTICS=1, even when the posthog flag is false or undefined", () => {
vi.stubEnv("VITE_MOCK_ANALYTICS", "1");
mockEnabled.mockReturnValue(false);
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
true,
);
mockEnabled.mockReturnValue(undefined);
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
true,
);
});
});

View file

@ -0,0 +1,13 @@
import { useFeatureFlagEnabled } from "posthog-js/react";
import { ANALYTICS_DASHBOARD_FLAG } from "@/util/featureFlags";
// VITE_MOCK_ANALYTICS fakes API responses (see cloud/dev/mockAnalyticsServer.ts)
// but can't reach real PostHog, so every ANALYTICS_DASHBOARD-gated surface
// would stay hidden in local dev without this override.
export function useAnalyticsDashboardFlag(): boolean | undefined {
const enabled = useFeatureFlagEnabled(ANALYTICS_DASHBOARD_FLAG);
if (import.meta.env.DEV && import.meta.env.VITE_MOCK_ANALYTICS === "1") {
return true;
}
return enabled;
}

View file

@ -0,0 +1,122 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import {
ClearOrganizationAuthTokenResponse,
CustomLLM,
CustomLLMCreateRequest,
CustomLLMListResponse,
CustomLLMResponse,
CustomLLMUpdateRequest,
} from "@/api/types";
import { useToast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "./useCredentialGetter";
const customLLMsQueryKey = ["customLLMs"] as const;
const modelsQueryKey = ["models"] as const;
function getErrorMessage(error: unknown, fallback: string) {
return (
(error as { response?: { data?: { detail?: string } } })?.response?.data
?.detail ||
(error as Error)?.message ||
fallback
);
}
export function useCustomLLMs() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const { toast } = useToast();
const query = useQuery<CustomLLMListResponse>({
queryKey: customLLMsQueryKey,
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
return client.get("/custom-llms").then((response) => response.data);
},
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: customLLMsQueryKey });
queryClient.invalidateQueries({ queryKey: modelsQueryKey });
};
const createMutation = useMutation({
mutationFn: async (request: CustomLLMCreateRequest) => {
const client = await getClient(credentialGetter, "sans-api-v1");
return client
.post("/custom-llms", request)
.then((response) => response.data as CustomLLMResponse);
},
onSuccess: () => {
invalidate();
toast({ title: "Success", description: "Custom LLM added" });
},
onError: (error: unknown) => {
toast({
title: "Error",
description: getErrorMessage(error, "Failed to add custom LLM"),
variant: "destructive",
});
},
});
const updateMutation = useMutation({
mutationFn: async ({
id,
request,
}: {
id: string;
request: CustomLLMUpdateRequest;
}) => {
const client = await getClient(credentialGetter, "sans-api-v1");
return client
.put(`/custom-llms/${id}`, request)
.then((response) => response.data as CustomLLMResponse);
},
onSuccess: () => {
invalidate();
toast({ title: "Success", description: "Custom LLM updated" });
},
onError: (error: unknown) => {
toast({
title: "Error",
description: getErrorMessage(error, "Failed to update custom LLM"),
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: async (customLLM: CustomLLM) => {
const client = await getClient(credentialGetter, "sans-api-v1");
return client
.delete(`/custom-llms/${customLLM.id}`)
.then(
(response) => response.data as ClearOrganizationAuthTokenResponse,
);
},
onSuccess: () => {
invalidate();
toast({ title: "Success", description: "Custom LLM deleted" });
},
onError: (error: unknown) => {
toast({
title: "Error",
description: getErrorMessage(error, "Failed to delete custom LLM"),
variant: "destructive",
});
},
});
return {
customLLMs: query.data?.custom_llms ?? [],
isLoading: query.isLoading,
createCustomLLM: createMutation.mutate,
updateCustomLLM: updateMutation.mutate,
deleteCustomLLM: deleteMutation.mutate,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
};
}

View file

@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { GoogleOAuthCredential } from "@/api/types";
import {
getGoogleOAuthCredentialScopesGranted,
getGoogleOAuthCredentialScopesRequested,
matchesGoogleOAuthIntegrationScopes,
normalizeGoogleOAuthScopes,
safePostCredentialsInvalidate,
} from "./useGoogleOAuthCredentials";
const baseCredential: GoogleOAuthCredential = {
@ -83,3 +84,33 @@ describe("Google OAuth credential scope helpers", () => {
).toBe(true);
});
});
describe("safePostCredentialsInvalidate", () => {
it("posts on a live channel", () => {
const postMessage = vi.fn();
safePostCredentialsInvalidate({ postMessage });
expect(postMessage).toHaveBeenCalledWith("invalidate");
});
it("no-ops when the channel is null", () => {
expect(() => safePostCredentialsInvalidate(null)).not.toThrow();
});
it("swallows errors from a disposed channel", () => {
const postMessage = vi.fn(() => {
throw new DOMException("channel is closed", "InvalidStateError");
});
expect(() => safePostCredentialsInvalidate({ postMessage })).not.toThrow();
expect(postMessage).toHaveBeenCalledWith("invalidate");
});
it("rethrows unexpected postMessage errors", () => {
const postMessage = vi.fn(() => {
throw new Error("unexpected broadcast failure");
});
expect(() => safePostCredentialsInvalidate({ postMessage })).toThrow(
"unexpected broadcast failure",
);
});
});

View file

@ -21,8 +21,25 @@ const credentialBroadcastChannel: BroadcastChannel | null =
? new BroadcastChannel(BROADCAST_CHANNEL_NAME)
: null;
// The channel can be disposed by the runtime (e.g. an embedded webview tearing
// down its native bridge during auth navigation) before this fires, in which
// case postMessage throws InvalidStateError. Cross-tab invalidation is
// best-effort, so swallow the failure rather than surfacing it.
export function safePostCredentialsInvalidate(
channel: Pick<BroadcastChannel, "postMessage"> | null,
): void {
try {
channel?.postMessage("invalidate");
} catch (error) {
if (error instanceof DOMException && error.name === "InvalidStateError") {
return;
}
throw error;
}
}
function broadcastCredentialsChanged() {
credentialBroadcastChannel?.postMessage("invalidate");
safePostCredentialsInvalidate(credentialBroadcastChannel);
}
export function normalizeGoogleOAuthScopes(

View file

@ -40,7 +40,13 @@ export function useNotificationStream() {
// Flatten authorization query building
let authQuery = "";
if (credentialGetter) {
authQuery = `?token=Bearer ${await credentialGetter()}`;
// A null token (transient Clerk failure) must not become "Bearer null":
// the server closes it 1002, which latches authFailedRef and stops all
// reconnects. Skip this cycle instead and reconnect on the next trigger.
const token = await credentialGetter();
if (token) {
authQuery = `?token=Bearer ${token}`;
}
} else if (getRuntimeApiKey()) {
authQuery = `?apikey=${getRuntimeApiKey()}`;
}

View file

@ -0,0 +1,52 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRecordingStore } from "@/store/useRecordingStore";
import { useRecordingElapsedSeconds } from "./useRecordingElapsedSeconds";
const BASE_MS = 1_700_000_000_000;
describe("useRecordingElapsedSeconds", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(BASE_MS);
useRecordingStore.setState({
recordingStartedAtMs: BASE_MS,
manualCapturePaused: false,
finishRequested: false,
});
});
afterEach(() => {
vi.useRealTimers();
});
it("counts up while recording", () => {
const { result } = renderHook(() => useRecordingElapsedSeconds());
expect(result.current).toBe(0);
act(() => vi.advanceTimersByTime(3000));
expect(Math.floor(result.current)).toBe(3);
});
it("freezes while paused and resumes without jumping the clock", () => {
const { result } = renderHook(() => useRecordingElapsedSeconds());
act(() => vi.advanceTimersByTime(3000));
expect(Math.floor(result.current)).toBe(3);
act(() => {
useRecordingStore.setState({ manualCapturePaused: true });
});
act(() => vi.advanceTimersByTime(5000));
// Frozen: the 5s spent paused must not count.
expect(Math.floor(result.current)).toBe(3);
act(() => {
useRecordingStore.setState({ manualCapturePaused: false });
});
// Resume must not jump forward by the paused span.
expect(Math.floor(result.current)).toBe(3);
act(() => vi.advanceTimersByTime(2000));
expect(Math.floor(result.current)).toBe(5);
});
});

View file

@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from "react";
import { useRecordingStore } from "@/store/useRecordingStore";
/**
* Elapsed recording time in seconds, excluding any time spent manually paused.
* The displayed clock freezes while `manualCapturePaused` is set and resumes
* from where it left off paused spans are folded into an accumulator rather
* than counted, so resume never jumps the clock forward.
*/
export function useRecordingElapsedSeconds(): number {
const startedAtMs = useRecordingStore((state) => state.recordingStartedAtMs);
const paused = useRecordingStore((state) => state.manualCapturePaused);
const finishRequested = useRecordingStore((state) => state.finishRequested);
const [nowMs, setNowMs] = useState(() => Date.now());
const pausedAccumMsRef = useRef(0);
const pauseStartedAtMsRef = useRef<number | null>(null);
// A new recording (new start timestamp) resets the paused accumulator.
useEffect(() => {
pausedAccumMsRef.current = 0;
pauseStartedAtMsRef.current = null;
}, [startedAtMs]);
useEffect(() => {
if (finishRequested) {
return;
}
if (paused) {
// Freeze: mark when the pause began and stop ticking.
if (pauseStartedAtMsRef.current === null) {
pauseStartedAtMsRef.current = Date.now();
setNowMs(Date.now());
}
return;
}
// Resuming: fold the just-ended pause into the accumulator so it is not
// counted, then keep ticking.
if (pauseStartedAtMsRef.current !== null) {
pausedAccumMsRef.current += Date.now() - pauseStartedAtMsRef.current;
pauseStartedAtMsRef.current = null;
}
setNowMs(Date.now());
const interval = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(interval);
}, [paused, finishRequested]);
if (!startedAtMs) {
return 0;
}
return (nowMs - startedAtMs - pausedAccumMsRef.current) / 1000;
}

View file

@ -1,7 +0,0 @@
import { useFeatureFlagEnabled } from "posthog-js/react";
// Read client-side so the flag reflects per-user opt-in (a PostHog person
// property) rather than the per-org server-evaluated path.
export function useWorkflowRunViewingV2(): boolean {
return useFeatureFlagEnabled("workflow_run_viewing_v2") ?? false;
}

View file

@ -54,11 +54,6 @@
--radius: 0.5rem;
/* studio editor brand accent (#6d6cf6) — opt-in via `studio-accent` utilities, scoped to the workflow studio */
--studio-accent: 244.8 84.4% 60%;
--studio-accent-foreground: 0 0% 100%;
--studio-accent-2: 244.5 100% 71.4%;
--slate-elevation-1: 0 0% 98%;
--slate-elevation-2: 0 0% 96%;
--slate-elevation-3: 0 0% 93%;

View file

@ -29,6 +29,10 @@ declare module "@novnc/novnc/lib/rfb.js" {
_display: RfbDisplay;
resizeSession: boolean;
scaleViewport: boolean;
// 09; JPEG quality for Tight-encoded regions (default 6).
qualityLevel: number;
// 09; zlib compression for stream data (default 2).
compressionLevel: number;
constructor(target: HTMLElement, url: string, options?: RFBOptions);
addEventListener(event: string, listener: (e: RfbEvent) => void): void;

View file

@ -5,6 +5,7 @@ import {
EditRoute,
StudioRoute,
} from "@/routes/workflows/StudioRouteGates";
import { LegacyWorkflowsRedirect } from "@/routes/workflows/LegacyWorkflowsRedirect";
import { BrowserSession } from "@/routes/browserSessions/BrowserSession";
import { BrowserSessions } from "@/routes/browserSessions/BrowserSessions";
import { PageLayout } from "./components/PageLayout";
@ -216,7 +217,11 @@ const router = createBrowserRouter([
],
},
{
path: "workflows",
path: "workflows/*",
element: <LegacyWorkflowsRedirect />,
},
{
path: "agents",
element: <WorkflowsPageLayout />,
children: [
{

View file

@ -3,7 +3,9 @@ import { Pencil1Icon } from "@radix-ui/react-icons";
import { useNavigate } from "react-router-dom";
import { BrowserProfileApiResponse } from "@/api/types";
import { CopyButton } from "@/components/CopyButton";
import { SelectionCheckboxCell } from "@/components/SelectionCheckbox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { TableCell, TableRow } from "@/components/ui/table";
import {
@ -67,7 +69,37 @@ function BrowserProfileItem({
/>
)}
<TableCell className="truncate">
<span title={profile.name}>{profile.name}</span>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate" title={profile.name}>
{profile.name}
</span>
{profile.is_managed ? (
<Badge
variant="secondary"
className="shrink-0"
title="Auto-saved from a Save & Reuse Session workflow. Recreated automatically on the next run."
>
Auto-managed
</Badge>
) : null}
</div>
<div
className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground"
onClick={stopRowClick}
>
<span
className="truncate font-mono"
title={profile.browser_profile_id}
>
{profile.browser_profile_id}
</span>
<CopyButton
value={profile.browser_profile_id}
className="size-5 shrink-0"
/>
</div>
</div>
</TableCell>
<TableCell className="truncate text-muted-foreground">
{profile.description ? (

View file

@ -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 = {}) {
<div className="rounded-md border border-slate-700 bg-slate-elevation1 p-10 text-sm text-neutral-600 dark:text-slate-300">
{hasSearch ? (
<>No browser profiles match &ldquo;{searchKey}&rdquo;.</>
) : managed === true ? (
<div className="flex flex-col items-center gap-3 text-center">
<BrowserIcon className="size-10 text-neutral-600 dark:text-slate-400" />
<div className="space-y-1">
<p className="text-base font-medium text-slate-100">
No auto-managed profiles yet
</p>
<p className="mx-auto max-w-md text-sm text-neutral-600 dark:text-slate-400">
Run a workflow with Save &amp; Reuse Session enabled and Skyvern
creates one automatically.
</p>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3 text-center">
<BrowserIcon className="size-10 text-neutral-600 dark:text-slate-400" />

View file

@ -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() {
</p>
</div>
<div className="flex items-center justify-between gap-4">
<TableSearchInput
value={search}
onChange={(value) => {
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"
/>
<div className="flex items-center gap-2">
<TableSearchInput
value={search}
onChange={(value) => {
setSearch(value);
resetToFirstPage();
}}
placeholder="Search browser profiles..."
className="w-48 lg:w-72"
/>
<select
aria-label="Filter browser profiles by type"
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
value={profileType}
onChange={(e) => {
setProfileType(e.target.value as "all" | "user" | "managed");
resetToFirstPage();
}}
>
<option value="all">All</option>
<option value="user">User profiles</option>
<option value="managed">Auto-managed</option>
</select>
</div>
<CreateBrowserProfileButton />
</div>
<BrowserProfilesList searchKey={debouncedSearch} />
<BrowserProfilesList searchKey={debouncedSearch} managed={managed} />
</div>
);
}

View file

@ -98,6 +98,7 @@ interface Props {
centered?: boolean;
onReadyChange?: (isReady: boolean, browserSessionId: string | null) => void;
onUrlChange?: (url: string) => void;
onActivity?: () => void;
}
function BrowserSessionStream({
@ -107,6 +108,7 @@ function BrowserSessionStream({
centered = false,
onReadyChange,
onUrlChange,
onActivity,
}: Props) {
const [streamImgSrc, setStreamImgSrc] = useState<string>("");
const [streamFormat, setStreamFormat] = useState<string>("png");
@ -119,6 +121,7 @@ function BrowserSessionStream({
const settingsStore = useSettingsStore();
const socketRef = useRef<WebSocket | null>(null);
const onActivityRef = useRef(onActivity);
const hasFrameRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const terminalStatusSeenRef = useRef(false);
@ -145,6 +148,18 @@ function BrowserSessionStream({
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("");
@ -188,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;
@ -205,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));
}

View file

@ -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 (
<TableRow
key={workflowRun.workflow_run_id}

View file

@ -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<number | null>(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<RecordingDraftStep> | 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<RecordingDraftStep>;
},
{
data: {
blocks: Array<WorkflowBlock>;
@ -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",

View file

@ -0,0 +1,12 @@
function AuthenticatorAppLogo({ src }: { src: string }) {
return (
<img
src={src}
alt=""
className="size-6 rounded-[6px] object-cover"
draggable={false}
/>
);
}
export { AuthenticatorAppLogo };

View file

@ -0,0 +1,58 @@
import {
createContext,
createElement,
useContext,
type ReactNode,
} from "react";
type CredentialEnterpriseAuthenticatorSupport = {
label: string;
apps: string[];
description: ReactNode;
contactUrl?: string;
vendorLabels?: Record<string, string>;
qrCodeTypes?: CredentialAuthenticatorQrCodeType[];
inferQrCodeType?: (value: string) => string | null;
};
type CredentialAuthenticatorQrCodeType = {
id: string;
label: string;
description?: ReactNode;
logo?: ReactNode;
};
type CredentialAuthenticatorSupportCopy = {
enterpriseApps?: CredentialEnterpriseAuthenticatorSupport;
};
const CredentialAuthenticatorSupportContext =
createContext<CredentialAuthenticatorSupportCopy>({});
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,
};

View file

@ -93,7 +93,14 @@ describe("CredentialItem TOTP code preview", () => {
mockedGetClient.mockResolvedValue({
get: vi.fn().mockRejectedValue({
isAxiosError: true,
response: { data: { detail: "Saved authenticator key is invalid." } },
response: {
data: {
detail: {
error_code: "invalid_authenticator_key",
message: "Saved authenticator key is invalid.",
},
},
},
}),
} as never);

View file

@ -34,8 +34,8 @@ import { useCredentialTestStore } from "@/store/useCredentialTestStore";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { toast } from "@/components/ui/use-toast";
import { copyText } from "@/util/copyText";
import { isAxiosError } from "axios";
import { cn } from "@/util/utils";
import { getCredentialErrorMessage } from "./authenticatorSaveError";
type Props = {
credential: CredentialApiResponse;
@ -120,14 +120,11 @@ function CredentialTotpCodePreview({ credentialId }: { credentialId: string }) {
if (!isMountedRef.current) {
return;
}
const detail = isAxiosError(caught)
? (caught.response?.data as { detail?: string } | undefined)?.detail
: undefined;
setTotpCode(null);
setSecondsRemaining(null);
setExpiresAt(null);
setIsCodeVisible(false);
setError(detail ?? "Unable to load code");
setError(getCredentialErrorMessage(caught) ?? "Unable to load code");
} finally {
if (isMountedRef.current) {
setIsLoading(false);

View file

@ -1,8 +1,130 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CredentialsModal
isOpen
onOpenChange={vi.fn()}
overrideType={CredentialModalTypes.PASSWORD}
/>
</MemoryRouter>
</QueryClientProvider>,
);
}
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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CredentialsModal
isOpen
onOpenChange={vi.fn()}
overrideType={CredentialModalTypes.PASSWORD}
editingCredential={editingPasswordCredential}
onStartBackgroundTest={vi.fn()}
/>
</MemoryRouter>
</QueryClientProvider>,
);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("getAuthenticatorKeyError", () => {
it("requires an authenticator key when authenticator 2FA is selected", () => {
@ -47,3 +169,164 @@ describe("getAuthenticatorKeyError", () => {
).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<HTMLInputElement>("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);
});

View file

@ -52,6 +52,11 @@ 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: "",
@ -248,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<AuthenticatorSaveError | null>(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 }));
@ -461,6 +505,7 @@ function CredentialsModal({
setCreditCardCredentialValues(createCreditCardCredentialInitialValues());
setSecretCredentialValues(SECRET_CREDENTIAL_INITIAL_VALUES);
setEditingGroups({ name: false, values: false });
setAuthenticatorSaveError(null);
setTestAndSave(false);
setTestUrl("");
setTestStatus("idle");
@ -618,6 +663,7 @@ function CredentialsModal({
);
const startTest = useCallback(async () => {
setAuthenticatorSaveError(null);
try {
const client = await getClient(credentialGetter, "sans-api-v1");
const proxyPinPayload = getProxyPinPayload();
@ -664,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,
@ -681,6 +723,7 @@ function CredentialsModal({
userContext,
pollTestStatus,
getProxyPinPayload,
reportCredentialSaveError,
]);
const createCredentialMutation = useMutation({
@ -747,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);
},
});
@ -829,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);
},
});
@ -900,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);
},
});
@ -969,9 +997,15 @@ function CredentialsModal({
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({
@ -985,6 +1019,22 @@ function CredentialsModal({
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.
@ -996,7 +1046,8 @@ function CredentialsModal({
saveIntentRef.current = {
shouldTestAfterSave:
testAndSave &&
testStatus !== "completed" &&
(testStatus !== "completed" ||
(isEditMode && hasCompletedInlineTest)) &&
testUrl.trim() !== "" &&
hasEditModeChanges,
saveBrowserSessionIntent: testAndSave,
@ -1263,7 +1314,7 @@ function CredentialsModal({
return (
<PasswordCredentialContent
values={passwordCredentialValues}
onChange={setPasswordCredentialValues}
onChange={handlePasswordCredentialChange}
url={testUrl}
onUrlChange={setTestUrl}
urlRequired={testAndSave}
@ -1273,6 +1324,7 @@ function CredentialsModal({
onEnableEditName={handleEnableEditName}
onEnableEditValues={handleEnableEditValues}
totpError={authenticatorKeyError}
authenticatorSaveError={authenticatorSaveError}
beforeCredentialFields={customVaultCheckbox}
afterUrl={
<div className="space-y-3">

View file

@ -12,6 +12,7 @@ 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 = {
@ -41,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.
@ -267,6 +298,14 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression
});
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();
});
@ -318,6 +357,12 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression
});
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();
});
@ -561,3 +606,321 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression
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 = (
<MemoryRouter>
<PasswordCredentialContent values={NEW_VALUES} onChange={onChangeSpy} />
</MemoryRouter>
);
render(
enterpriseApps ? (
<CredentialAuthenticatorSupportProvider value={{ enterpriseApps }}>
{content}
</CredentialAuthenticatorSupportProvider>
) : (
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(
<MemoryRouter>
<CredentialAuthenticatorSupportProvider
value={{ enterpriseApps: ENTERPRISE_APPS }}
>
<PasswordCredentialContent
values={AUTH_VALUES}
onChange={vi.fn()}
authenticatorSaveError={{
code: "enterprise_required",
message: "This authenticator requires a Skyvern enterprise plan.",
vendor: "opaque",
}}
/>
</CredentialAuthenticatorSupportProvider>
</MemoryRouter>,
);
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(
<MemoryRouter>
<PasswordCredentialContent
values={AUTH_VALUES}
onChange={vi.fn()}
authenticatorSaveError={{
code: "no_code_secret",
message:
"This QR code doesn't contain a code-based setup key. It may enroll a push-approval app or device-bound authenticator. Set up an authenticator app or one-time code instead.",
}}
/>
</MemoryRouter>,
);
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(
<MemoryRouter>
<PasswordCredentialContent
values={AUTH_VALUES}
onChange={vi.fn()}
authenticatorSaveError={{
code: "invalid_authenticator_key",
message: "Invalid authenticator key.",
}}
/>
</MemoryRouter>,
);
const input = screen.getByPlaceholderText(
"e.g. JBSWY3DPEHPK3PXP",
) as HTMLInputElement;
expect(input.value).toBe("otpauth://totp/user@example.com?secret=BAD");
});
});

View file

@ -1,3 +1,4 @@
import googleAuthenticatorIcon from "@/assets/authenticators/google-authenticator.jpg";
import { QRCodeIcon } from "@/components/icons/QRCodeIcon";
import {
Accordion,
@ -15,14 +16,28 @@ import {
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: {
@ -57,8 +72,39 @@ type Props = {
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: <AuthenticatorAppLogo src={googleAuthenticatorIcon} />,
};
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,
@ -73,8 +119,29 @@ function PasswordCredentialContent({
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;
@ -87,7 +154,101 @@ function PasswordCredentialContent({
const [showPassword, setShowPassword] = useState(false);
const [qrCodeScanError, setQrCodeScanError] = useState<string | null>(null);
const [isScanningQrCode, setIsScanningQrCode] = useState(false);
const [selectedAuthenticatorTypeId, setSelectedAuthenticatorTypeId] =
useState<string | null>(STANDARD_AUTHENTICATOR_TYPE_ID);
const [inferredAuthenticatorTypeId, setInferredAuthenticatorTypeId] =
useState<string | null>(null);
const [
authenticatorTypeDetectionSource,
setAuthenticatorTypeDetectionSource,
] = useState<AuthenticatorTypeDetectionSource | null>(null);
const inferredAuthenticatorValueRef = useRef("");
const qrCodeInputRef = useRef<HTMLInputElement>(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)
@ -101,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"
@ -196,6 +404,14 @@ function PasswordCredentialContent({
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" });
};
@ -214,6 +430,9 @@ function PasswordCredentialContent({
try {
const qrCodeValue = await decodeQrCodeImage(file);
setTotpMethod("authenticator");
applyAuthenticatorTypeInference(qrCodeValue, "qr", {
clearUnknownSelection: true,
});
updateValues({ totp: qrCodeValue, totp_type: "authenticator" });
} catch (caught) {
setQrCodeScanError(
@ -226,6 +445,94 @@ function PasswordCredentialContent({
}
};
const authenticatorTypeSelector = (
<div className="space-y-2" data-testid="authenticator-qr-type-selector">
<div className="flex items-center justify-between gap-3">
<Label className="text-xs font-medium text-slate-400">
Authenticator app
</Label>
<div role="status" aria-live="polite" className="min-h-4">
{detectionStatusLabel && inferredAuthenticatorTypeId && (
<>
<span
data-testid="authenticator-qr-type-detection"
className="text-xs text-blue-300"
>
{detectionStatusLabel}
</span>
{inferredAuthenticatorTypeLabel && (
<span className="sr-only">
{`${inferredAuthenticatorTypeLabel}`}
</span>
)}
</>
)}
</div>
</div>
<div
role="group"
aria-label="Authenticator app type"
className="grid grid-cols-2 gap-2 sm:grid-cols-4"
>
{authenticatorTypeOptions.map((option) => {
const selected = selectedAuthenticatorTypeId === option.id;
const inferred = inferredAuthenticatorTypeId === option.id;
return (
<button
key={option.id}
type="button"
aria-pressed={selected}
data-inferred={inferred ? "true" : undefined}
className={cn(
"inline-flex h-14 w-full min-w-0 items-center gap-2 rounded-md border px-3 text-left text-xs font-medium leading-tight transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected
? "border-blue-400 bg-blue-500/10 text-foreground"
: "border-slate-700 bg-slate-elevation1 text-slate-300 hover:border-slate-500 hover:bg-slate-elevation3 hover:text-slate-100",
inferred &&
"border-blue-300 bg-blue-500/15 ring-1 ring-blue-400/70",
)}
onClick={() => {
onEnableEditValues?.();
setSelectedAuthenticatorTypeId(option.id);
setInferredAuthenticatorTypeId(null);
setAuthenticatorTypeDetectionSource(null);
}}
>
<span
aria-hidden="true"
data-testid="authenticator-type-logo"
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md border",
selected || inferred
? "border-blue-400/50 bg-blue-500/10"
: "border-slate-700 bg-background/40",
)}
>
{option.logo ?? <QRCodeIcon className="size-4" />}
</span>
<span className="min-w-0 flex-1">{option.label}</span>
<span className="ml-auto flex size-4 shrink-0 items-center justify-center">
{inferred ? (
<MagicWandIcon className="size-3 text-blue-300" />
) : (
selected && <CheckIcon className="size-3" />
)}
</span>
{inferred && detectionStatusLabel && (
<span className="sr-only">{detectionStatusLabel}</span>
)}
</button>
);
})}
</div>
{enterpriseApps?.description && (
<p className="text-xs leading-5 text-slate-400">
{enterpriseApps.description}
</p>
)}
</div>
);
return (
<div className="space-y-5">
<div className="flex items-center gap-12">
@ -448,24 +755,28 @@ function PasswordCredentialContent({
</p>
</div>
<p className="text-sm text-slate-400">
<Link
to="https://www.skyvern.com/contact"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
Contact us to set up two-factor authentication in
workflows
</Link>{" "}
or{" "}
{enterpriseContactUrl && (
<>
<Link
to={enterpriseContactUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
Contact us to set up two-factor authentication in
workflows
</Link>{" "}
or{" "}
</>
)}
<Link
to="https://www.skyvern.com/docs/running-tasks/advanced-features#time-based-one-time-password-totp"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
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
</Link>{" "}
to get started.
</p>
@ -473,6 +784,7 @@ function PasswordCredentialContent({
)}
{totpMethod === "authenticator" && (
<div className="space-y-4">
{authenticatorTypeSelector}
<div className="flex items-center gap-12">
<div className="w-40 shrink-0">
<Label className="whitespace-nowrap">
@ -504,9 +816,12 @@ function PasswordCredentialContent({
handleAuthenticatorTotpChange(e.target.value)
}
placeholder="e.g. JBSWY3DPEHPK3PXP"
aria-invalid={Boolean(totpError)}
aria-invalid={hasAuthenticatorKeyError}
aria-describedby={
authenticatorKeyDescriptionIds || undefined
}
className={cn(
totpError &&
hasAuthenticatorKeyError &&
"border-destructive bg-destructive/10 focus-visible:ring-destructive/30",
)}
/>
@ -537,12 +852,49 @@ function PasswordCredentialContent({
</div>
)}
</div>
{(totpError || qrCodeScanError) && (
<div className="space-y-1 text-xs text-destructive">
{totpError && <p>{totpError}</p>}
{qrCodeScanError && <p>{qrCodeScanError}</p>}
{destructiveAuthenticatorMessages.length > 0 && (
<div
id={authenticatorKeyErrorId}
className="space-y-1 text-xs text-destructive"
>
{destructiveAuthenticatorMessages.map(
(message, index) => (
<p key={`${index}-${message}`}>{message}</p>
),
)}
</div>
)}
{enterpriseUpgradeError && (
<div
id={enterpriseUpgradeErrorId}
data-testid="enterprise-authenticator-upgrade"
className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-sm leading-5 text-foreground"
>
<span className="font-medium">
{enterpriseUpgradeMessage}
</span>
{enterpriseContactUrl && (
<>
{" "}
<span className="text-muted-foreground">
<Link
to={enterpriseContactUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-warning underline underline-offset-2"
>
Contact us
</Link>{" "}
to enable enterprise authenticator support.
</span>
</>
)}
</div>
)}
<p className="text-sm text-slate-400">
Works with Google Authenticator, Authy, 1Password, and any
standard TOTP authenticator app.
</p>
<p className="text-sm text-slate-400">
You need to find the authenticator key from the website
where you are using the credential. Here are some guides

View file

@ -0,0 +1,147 @@
// @vitest-environment jsdom
import { AxiosError, AxiosHeaders } from "axios";
import { describe, expect, it } from "vitest";
import {
getAuthenticatorSaveError,
getCredentialErrorMessage,
} from "./authenticatorSaveError";
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;
}
describe("getAuthenticatorSaveError", () => {
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();
});
});

View file

@ -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<string, AuthenticatorErrorCode> = {
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 };

Some files were not shown because too many files have changed in this diff Show more