diff --git a/README.md b/README.md index 5174ce7..b908506 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Read more: | Quickstart | `forge`, `run` | | Browser lifecycle | `browser start`, `browser stop`, `browser sessions`, `browser live` | | Browser passthrough | `steel browser ` | +| Browser profiles | `profile import`, `profile sync`, `profile list`, `profile delete` | | API tools | `scrape`, `screenshot`, `pdf` | | Local runtime | `dev install`, `dev start`, `dev stop` | | Account and utility | `login`, `logout`, `config`, `settings`, `cache`, `docs`, `support`, `star`, `update` | diff --git a/docs/README.md b/docs/README.md index c0a6372..3fd3617 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ This folder contains both generated command docs and hand-maintained migration/r - `migration-agent-browser.md`: migration guide from `agent-browser` to `steel browser`. - `upstream-sync.md`: maintainer guide for vendored runtime updates. - `references/`: stable quick-reference docs and synced upstream command catalogs. + - Profile commands and persistence are documented in `references/steel-browser.md` and `references/steel-cli.md`. ## References Subfolder diff --git a/docs/references/steel-browser.md b/docs/references/steel-browser.md index db4ec7e..56c869e 100644 --- a/docs/references/steel-browser.md +++ b/docs/references/steel-browser.md @@ -62,6 +62,8 @@ Main flags: - `--session-solve-captcha` - `--namespace ` - `--credentials` +- `--profile ` +- `--update-profile` Flag semantics: @@ -78,7 +80,12 @@ Flag semantics: stored under this namespace will be available for injection. - `--credentials` enables credential injection for the session. Sends `credentials: {}` in the session creation payload. -- `--stealth`, `--proxy`, `--namespace`, and `--credentials` are create-time flags. +- `--profile ` loads a previously imported browser profile (cookies, local + storage, etc.) into the session. +- `--update-profile` saves session state back to the profile when the session ends. + Requires `--profile`. +- `--stealth`, `--proxy`, `--namespace`, `--credentials`, `--profile`, and + `--update-profile` are create-time flags. If `--session ` attaches to an existing live session, these values are not re-applied. @@ -161,6 +168,28 @@ API mapping: - Request body: optional `pageId`, `url`, `taskId` - Response: `success` + optional `message` +## Profile Persistence + +Profiles let sessions start with pre-existing browser state (cookies, local storage, IndexedDB, etc.) imported from a local Chrome installation. + +Setup (macOS only, run by user): + +```bash +steel profile import --name myapp # import Chrome profile to Steel +steel profile sync --name myapp # re-sync local changes to Steel +steel profile list # list saved profiles +steel profile delete --name myapp # remove local metadata +``` + +Usage in sessions: + +```bash +steel browser start --session job --profile myapp # load profile +steel browser start --session job --profile myapp --update-profile # load + save back +``` + +Profile metadata is stored at `~/.config/steel/profiles/.json`. + ## Passthrough Bootstrap Rules For inherited commands, Steel bootstrap injects a resolved `--cdp` endpoint unless explicit attach flags are present. diff --git a/docs/references/steel-cli.md b/docs/references/steel-cli.md index cd90ebc..922270f 100644 --- a/docs/references/steel-cli.md +++ b/docs/references/steel-cli.md @@ -19,6 +19,13 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer - `steel browser live`: print the active session live-view URL. - `steel browser `: pass through to vendored `agent-browser` runtime. +### Profile Commands + +- `steel profile import`: import a local Chrome profile into Steel (macOS only). +- `steel profile sync`: sync a local Chrome profile to an existing Steel profile (macOS only). +- `steel profile list`: list all saved Steel browser profiles. +- `steel profile delete`: delete a saved Steel profile (local metadata only). + ### Credentials Commands - `steel credentials create`: store a new credential for a given origin. @@ -67,6 +74,7 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer - Config directory: `~/.config/steel` - Main config: `~/.config/steel/config.json` - Browser session state: `~/.config/steel/browser-session-state.json` +- Profile metadata: `~/.config/steel/profiles/.json` ## Environment Variables (Common) @@ -75,6 +83,7 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer - `STEEL_BROWSER_API_URL`: canonical self-hosted local endpoint override. - `STEEL_LOCAL_API_URL`: backward-compatible self-hosted alias. - `STEEL_CONFIG_DIR`: override config directory root. +- `STEEL_PROFILE`: default profile name for browser sessions. ## Key References diff --git a/package-lock.json b/package-lock.json index 592cb2a..52cc5df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,18 @@ { "name": "@steel-dev/cli", - "version": "0.1.8", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@steel-dev/cli", - "version": "0.1.8", + "version": "0.2.1", "license": "MIT", "dependencies": { "@babel/parser": "^7.27.2", + "@types/better-sqlite3": "^7.6.13", + "better-sqlite3": "^11.10.0", + "fflate": "^0.8.2", "figures": "^6.1.0", "ink": "^5.2.1", "ink-big-text": "^2.0.0", @@ -1758,6 +1761,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3189,6 +3201,75 @@ "node": ">=10.0.0" } }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/blacklist": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/blacklist/-/blacklist-1.1.4.tgz", @@ -3509,6 +3590,12 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -4018,6 +4105,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4028,6 +4130,15 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4177,6 +4288,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -5505,6 +5625,15 @@ "node": ">=0.8.x" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -5650,6 +5779,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -5678,6 +5813,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5794,6 +5935,12 @@ "node": ">=0.4.x" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6002,6 +6149,12 @@ "node": ">= 14" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -6443,6 +6596,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ink": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", @@ -8578,6 +8737,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -8597,7 +8768,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8618,6 +8788,12 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/modern-tar": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.5.tgz", @@ -8663,6 +8839,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", @@ -8695,6 +8877,18 @@ "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-simctl": { "version": "7.7.5", "resolved": "https://registry.npmjs.org/node-simctl/-/node-simctl-7.7.5.tgz", @@ -9346,6 +9540,75 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9501,6 +9764,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -10241,6 +10528,51 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slice-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", @@ -11098,6 +11430,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index f0ca36d..73f24fd 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ ], "dependencies": { "@babel/parser": "^7.27.2", + "@types/better-sqlite3": "^7.6.13", + "better-sqlite3": "^11.10.0", + "fflate": "^0.8.2", "figures": "^6.1.0", "ink": "^5.2.1", "ink-big-text": "^2.0.0", diff --git a/skills/steel-browser/references/steel-browser-lifecycle.md b/skills/steel-browser/references/steel-browser-lifecycle.md index 72f74cb..1cf34f3 100644 --- a/skills/steel-browser/references/steel-browser-lifecycle.md +++ b/skills/steel-browser/references/steel-browser-lifecycle.md @@ -49,6 +49,8 @@ Main flags: - `--session-solve-captcha` - `--namespace ` — credential namespace for the session - `--credentials` — enable credential injection for the session +- `--profile ` — load a named browser profile into the session +- `--update-profile` — save session state back to the profile on end Parse these output fields: @@ -137,6 +139,17 @@ Main flags: - `--local` - `--api-url ` +## Profile persistence + +```bash +steel browser start --session "$SESSION" --profile myapp --update-profile +``` + +- `--profile `: Load a previously imported browser profile (cookies, storage, etc.) into the session. +- `--update-profile`: Save session state back to the profile when the session ends. + +Profiles are created outside agent workflows via `steel profile import`. Agents should only consume them with the flags above. + ## Passthrough bootstrap behavior For inherited commands, Steel may inject resolved `--cdp` automatically. diff --git a/source/commands/browser/start.tsx b/source/commands/browser/start.tsx index cf699e7..e3e9404 100644 --- a/source/commands/browser/start.tsx +++ b/source/commands/browser/start.tsx @@ -91,6 +91,24 @@ export const options = zod.object({ }), ) .optional(), + profile: zod + .string() + .describe( + option({ + description: + 'Named profile to persist browser state across sessions (stored in ~/.config/steel/profiles/)', + }), + ) + .optional(), + updateProfile: zod + .boolean() + .describe( + option({ + description: + 'Save session state back to the profile when the session ends (default: false — profile is loaded read-only)', + }), + ) + .optional(), namespace: zod .string() .describe( @@ -127,6 +145,8 @@ export default function Start({options}: Props) { headless: options.sessionHeadless, region: options.sessionRegion, solveCaptcha: options.sessionSolveCaptcha, + profileName: options.profile, + updateProfile: options.updateProfile, namespace: options.namespace, credentials: options.credentials, }); @@ -167,6 +187,7 @@ export default function Start({options}: Props) { }, [ options.apiUrl, options.local, + options.profile, options.proxy, options.sessionHeadless, options.sessionRegion, @@ -174,6 +195,7 @@ export default function Start({options}: Props) { options.sessionTimeout, options.session, options.stealth, + options.updateProfile, options.namespace, options.credentials, ]); diff --git a/source/commands/profile/delete.tsx b/source/commands/profile/delete.tsx new file mode 100644 index 0000000..45a4886 --- /dev/null +++ b/source/commands/profile/delete.tsx @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +import {useEffect} from 'react'; +import zod from 'zod'; +import {option} from 'pastel'; +import { + deleteSteelProfile, + validateProfileName, +} from '../../utils/browser/lifecycle/profile-store.js'; + +export const description = + 'Delete a saved Steel browser profile (local file only)'; + +export const options = zod.object({ + name: zod.string().describe( + option({ + description: 'Name of the profile to delete', + }), + ), +}); + +type Props = { + options: zod.infer; +}; + +export default function Delete({options}: Props) { + useEffect(() => { + async function run() { + const nameError = validateProfileName(options.name); + if (nameError) { + console.error(nameError); + process.exit(1); + return; + } + + const deleted = await deleteSteelProfile(options.name, process.env); + + if (!deleted) { + console.error(`Profile "${options.name}" not found.`); + process.exit(1); + return; + } + + console.log( + `Deleted profile "${options.name}". Note: Browser state on Steel servers is not affected.`, + ); + process.exit(0); + } + + run(); + }, [options.name]); +} diff --git a/source/commands/profile/import.tsx b/source/commands/profile/import.tsx new file mode 100644 index 0000000..269624a --- /dev/null +++ b/source/commands/profile/import.tsx @@ -0,0 +1,276 @@ +#!/usr/bin/env node + +import * as React from 'react'; +import {Box, Text} from 'ink'; +import Spinner from 'ink-spinner'; +import SelectInput from 'ink-select-input'; +import zod from 'zod'; +import {option} from 'pastel'; +import { + findChromeProfiles, + isChromeRunning, + packageChromeProfile, + uploadProfileToSteel, + type ChromeProfile, +} from '../../utils/browser/profile-porter.js'; +import { + validateProfileName, + writeSteelProfile, +} from '../../utils/browser/lifecycle/profile-store.js'; +import {resolveBrowserAuth} from '../../utils/browser/auth.js'; +import {DEFAULT_API_PATH} from '../../utils/browser/lifecycle/constants.js'; + +export const description = + 'Import a local Chrome profile into Steel (macOS only)'; + +export const options = zod.object({ + name: zod.string().describe( + option({ + description: 'Steel profile name to save as', + }), + ), + from: zod + .string() + .describe( + option({ + description: + 'Chrome profile to import from (e.g. "Default", "Profile 1")', + }), + ) + .optional(), +}); + +type Props = { + options: zod.infer; +}; + +type Phase = + | {tag: 'checking'} + | {tag: 'selecting'; profiles: ChromeProfile[]; chromeRunning: boolean} + | {tag: 'keychain'} + | {tag: 'importing'; chromeProfile: ChromeProfile; step: string} + | {tag: 'done'; profileId: string; cookiesReencrypted: number; zipMb: string} + | {tag: 'error'; message: string}; + +export default function Import({options}: Props) { + const [phase, setPhase] = React.useState({tag: 'checking'}); + + React.useEffect(() => { + const nameError = validateProfileName(options.name); + if (nameError) { + setPhase({tag: 'error', message: nameError}); + return; + } + + if (process.platform !== 'darwin') { + setPhase({ + tag: 'error', + message: '`steel profile import` is currently macOS only.', + }); + return; + } + + const auth = resolveBrowserAuth(process.env); + if (!auth.apiKey) { + setPhase({ + tag: 'error', + message: 'No API key found. Run `steel login` or set STEEL_API_KEY.', + }); + return; + } + + const profiles = findChromeProfiles(); + if (profiles.length === 0) { + setPhase({ + tag: 'error', + message: 'No Chrome profiles found.', + }); + return; + } + + if (options.from) { + const match = profiles.find(p => p.dirName === options.from); + if (!match) { + setPhase({ + tag: 'error', + message: `Chrome profile "${options.from}" not found. Available: ${profiles.map(p => p.dirName).join(', ')}`, + }); + return; + } + + runImport(match, auth.apiKey!); + return; + } + + // No --from: show picker + setPhase({ + tag: 'selecting', + profiles, + chromeRunning: isChromeRunning(), + }); + }, []); + + function runImport(chromeProfile: ChromeProfile, apiKey: string) { + setPhase({tag: 'importing', chromeProfile, step: 'Starting...'}); + + (async () => { + let zipBuffer: Buffer; + let cookiesReencrypted: number; + let zipMb: string; + + try { + ({zipBuffer, cookiesReencrypted} = await packageChromeProfile( + chromeProfile.dirName, + msg => { + setPhase({ + tag: 'importing', + chromeProfile, + step: msg, + }); + }, + () => { + setPhase({tag: 'keychain'}); + }, + )); + zipMb = (zipBuffer.length / 1024 / 1024).toFixed(1); + } catch (error) { + setPhase({ + tag: 'error', + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + setPhase({ + tag: 'importing', + chromeProfile, + step: 'Uploading to Steel...', + }); + + let profileId: string; + try { + profileId = await uploadProfileToSteel( + zipBuffer, + apiKey, + DEFAULT_API_PATH, + ); + } catch (error) { + setPhase({ + tag: 'error', + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + await writeSteelProfile( + options.name, + profileId, + process.env, + chromeProfile.dirName, + ); + + setPhase({tag: 'done', profileId, cookiesReencrypted, zipMb}); + process.exit(0); + })(); + } + + function handleSelect(item: {value: string}) { + if (phase.tag !== 'selecting') return; + const profile = phase.profiles.find(p => p.dirName === item.value)!; + const auth = resolveBrowserAuth(process.env); + runImport(profile, auth.apiKey!); + } + + if (phase.tag === 'checking') { + return ( + + + + + Checking... + + ); + } + + if (phase.tag === 'selecting') { + const items = phase.profiles.map(p => ({ + label: `${p.displayName} ${p.dirName !== p.displayName ? `(${p.dirName})` : ''}`, + value: p.dirName, + })); + + return ( + + {phase.chromeRunning && ( + + + ⚠ Chrome is running. Close it for best results (cookie file may + be locked). + + + )} + Select Chrome profile to import: + + + ); + } + + if (phase.tag === 'keychain') { + return ( + + + macOS will ask for your password to read Chrome's cookie encryption + key from Keychain. + + + ); + } + + if (phase.tag === 'importing') { + return ( + + + + + + {' '} + {phase.chromeProfile.displayName} → {options.name} + {' '} + {phase.step} + + + ); + } + + if (phase.tag === 'done') { + return ( + + + + + Imported as {options.name} + + + + id: {phase.profileId} + + cookies: {phase.cookiesReencrypted} re-encrypted · {phase.zipMb} MB + + + + steel browser start --profile {options.name} + + Add --update-profile to save session changes back to the profile + + + + ); + } + + // error + return ( + + + {(phase as {tag: 'error'; message: string}).message} + + ); +} diff --git a/source/commands/profile/index.tsx b/source/commands/profile/index.tsx new file mode 100644 index 0000000..bbb3c99 --- /dev/null +++ b/source/commands/profile/index.tsx @@ -0,0 +1 @@ +export const description = 'Manage named Steel browser profiles'; diff --git a/source/commands/profile/list.tsx b/source/commands/profile/list.tsx new file mode 100644 index 0000000..21a9b41 --- /dev/null +++ b/source/commands/profile/list.tsx @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import {useEffect} from 'react'; +import zod from 'zod'; +import {option} from 'pastel'; +import {listSteelProfiles} from '../../utils/browser/lifecycle/profile-store.js'; + +export const description = 'List all saved Steel browser profiles'; + +export const options = zod.object({ + json: zod + .boolean() + .describe( + option({ + description: 'Output profiles as JSON', + }), + ) + .optional(), +}); + +type Props = { + options: zod.infer; +}; + +export default function List({options}: Props) { + useEffect(() => { + async function run() { + const profiles = await listSteelProfiles(process.env); + + if (options.json) { + console.log(JSON.stringify(profiles, null, 2)); + process.exit(0); + return; + } + + if (profiles.length === 0) { + console.log( + 'No profiles found. Use --profile with steel browser start to create one.', + ); + process.exit(0); + return; + } + + const nameWidth = Math.max(4, ...profiles.map(p => p.name.length)); + console.log(`${'NAME'.padEnd(nameWidth)} PROFILE_ID`); + for (const profile of profiles) { + console.log(`${profile.name.padEnd(nameWidth)} ${profile.profileId}`); + } + + process.exit(0); + } + + run(); + }, [options.json]); +} diff --git a/source/commands/profile/sync.tsx b/source/commands/profile/sync.tsx new file mode 100644 index 0000000..6c314c1 --- /dev/null +++ b/source/commands/profile/sync.tsx @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +import * as React from 'react'; +import {Box, Text} from 'ink'; +import Spinner from 'ink-spinner'; +import zod from 'zod'; +import {option} from 'pastel'; +import { + findChromeProfiles, + isChromeRunning, + packageChromeProfile, + updateProfileOnSteel, + type ChromeProfile, +} from '../../utils/browser/profile-porter.js'; +import { + readSteelProfile, + validateProfileName, + writeSteelProfile, +} from '../../utils/browser/lifecycle/profile-store.js'; +import {resolveBrowserAuth} from '../../utils/browser/auth.js'; +import {DEFAULT_API_PATH} from '../../utils/browser/lifecycle/constants.js'; + +export const description = + 'Sync a local Chrome profile to an existing Steel profile'; + +export const options = zod.object({ + name: zod.string().describe( + option({ + description: 'Steel profile name to sync', + }), + ), + from: zod + .string() + .describe( + option({ + description: 'Chrome profile to sync from (overrides stored source)', + }), + ) + .optional(), +}); + +type Props = { + options: zod.infer; +}; + +type Phase = + | {tag: 'keychain'} + | {tag: 'syncing'; chromeProfile: ChromeProfile; step: string} + | {tag: 'done'; cookiesReencrypted: number; zipMb: string} + | {tag: 'error'; message: string}; + +export default function Sync({options}: Props) { + const [phase, setPhase] = React.useState(null); + + React.useEffect(() => { + (async () => { + const nameError = validateProfileName(options.name); + if (nameError) { + setPhase({tag: 'error', message: nameError}); + return; + } + + if (process.platform !== 'darwin') { + setPhase({ + tag: 'error', + message: '`steel profile sync` is currently macOS only.', + }); + return; + } + + const auth = resolveBrowserAuth(process.env); + if (!auth.apiKey) { + setPhase({ + tag: 'error', + message: 'No API key found. Run `steel login` or set STEEL_API_KEY.', + }); + return; + } + + const stored = await readSteelProfile(options.name, process.env); + if (!stored) { + setPhase({ + tag: 'error', + message: `Profile "${options.name}" not found. Run \`steel profile import --name ${options.name}\` first.`, + }); + return; + } + + const chromeProfileDirName = options.from ?? stored.chromeProfile; + if (!chromeProfileDirName) { + setPhase({ + tag: 'error', + message: `No source Chrome profile stored for "${options.name}". Specify one with --from.`, + }); + return; + } + + const allProfiles = findChromeProfiles(); + const chromeProfile = allProfiles.find( + p => p.dirName === chromeProfileDirName, + ); + if (!chromeProfile) { + setPhase({ + tag: 'error', + message: `Chrome profile "${chromeProfileDirName}" not found. Available: ${allProfiles.map(p => p.dirName).join(', ')}`, + }); + return; + } + + if (isChromeRunning()) { + console.error( + 'Warning: Chrome is running. Close it for best results (cookie file may be locked).', + ); + } + + setPhase({tag: 'syncing', chromeProfile, step: 'Starting...'}); + + let zipBuffer: Buffer; + let cookiesReencrypted: number; + + try { + ({zipBuffer, cookiesReencrypted} = await packageChromeProfile( + chromeProfile.dirName, + msg => { + setPhase({tag: 'syncing', chromeProfile, step: msg}); + }, + () => { + setPhase({tag: 'keychain'}); + }, + )); + } catch (error) { + setPhase({ + tag: 'error', + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + setPhase({ + tag: 'syncing', + chromeProfile, + step: 'Uploading to Steel...', + }); + + try { + await updateProfileOnSteel( + stored.profileId, + zipBuffer, + auth.apiKey, + DEFAULT_API_PATH, + ); + } catch (error) { + setPhase({ + tag: 'error', + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + if (options.from && options.from !== stored.chromeProfile) { + await writeSteelProfile( + options.name, + stored.profileId, + process.env, + options.from, + ); + } + + const zipMb = (zipBuffer.length / 1024 / 1024).toFixed(1); + setPhase({tag: 'done', cookiesReencrypted, zipMb}); + process.exit(0); + })(); + }, []); + + if (phase === null) { + return ( + + + + + Checking... + + ); + } + + if (phase.tag === 'keychain') { + return ( + + + macOS will ask for your password to read Chrome's cookie encryption + key from Keychain. + + + ); + } + + if (phase.tag === 'syncing') { + return ( + + + + + + {' '} + {phase.chromeProfile.displayName} → {options.name} + {' '} + {phase.step} + + + ); + } + + if (phase.tag === 'done') { + return ( + + + + + Synced {options.name} + + + + + {phase.cookiesReencrypted} cookies re-encrypted · {phase.zipMb} MB + + + + ); + } + + return ( + + + {phase.message} + + ); +} diff --git a/source/steel.tsx b/source/steel.tsx index ef2961b..1e054db 100644 --- a/source/steel.tsx +++ b/source/steel.tsx @@ -8,8 +8,6 @@ import { checkAndUpdate, getCurrentVersion, setGlobalUpdateInfo, - subscribeToUpdateState, - type UpdateState, } from './utils/update.js'; import { filterSteelGlobalFlags, diff --git a/source/utils/browser/lifecycle.ts b/source/utils/browser/lifecycle.ts index 2215109..c15cff1 100644 --- a/source/utils/browser/lifecycle.ts +++ b/source/utils/browser/lifecycle.ts @@ -9,6 +9,11 @@ import { solveSessionCaptchaFromApi, } from './lifecycle/api-client.js'; import {parseBrowserPassthroughBootstrapFlags} from './lifecycle/bootstrap-flags.js'; +import { + readSteelProfile, + validateProfileName, + writeSteelProfile, +} from './lifecycle/profile-store.js'; import { buildDeadSessionMessage, getSessionId, @@ -159,21 +164,71 @@ export async function startBrowserSession( continue; } - const createdSession = await createSessionFromApi( - mode, - { - stealth: options.stealth, - proxyUrl: options.proxyUrl, - timeoutMs: options.timeoutMs, - headless: options.headless, - region: options.region, - solveCaptcha: options.solveCaptcha, - namespace: options.namespace, - credentials: options.credentials, - }, - environment, - apiUrl, + let resolvedProfileId: string | undefined; + let storedChromeProfile: string | undefined; + + if (options.profileName) { + const nameError = validateProfileName(options.profileName); + if (nameError) { + throw new BrowserAdapterError('INVALID_BROWSER_ARGS', nameError); + } + + const stored = await readSteelProfile(options.profileName, environment); + resolvedProfileId = stored?.profileId; + storedChromeProfile = stored?.chromeProfile; + } + + const persistProfile = Boolean( + options.profileName && options.updateProfile, ); + + let createdSession: UnknownRecord; + try { + createdSession = await createSessionFromApi( + mode, + { + stealth: options.stealth, + proxyUrl: options.proxyUrl, + timeoutMs: options.timeoutMs, + headless: options.headless, + region: options.region, + solveCaptcha: options.solveCaptcha, + profileId: resolvedProfileId, + persistProfile, + namespace: options.namespace, + credentials: options.credentials, + }, + environment, + apiUrl, + ); + } catch (error) { + if ( + options.profileName && + resolvedProfileId && + isNotFoundApiError(error) + ) { + console.error(`Warning: Profile not found. Creating a new profile.`); + createdSession = await createSessionFromApi( + mode, + { + stealth: options.stealth, + proxyUrl: options.proxyUrl, + timeoutMs: options.timeoutMs, + headless: options.headless, + region: options.region, + solveCaptcha: options.solveCaptcha, + persistProfile, + namespace: options.namespace, + credentials: options.credentials, + }, + environment, + apiUrl, + ); + } else { + throw error; + } + } + const createdSessionId = getSessionId(createdSession); if (!createdSessionId) { @@ -201,6 +256,18 @@ export async function startBrowserSession( }); if (claimedCreatedSession) { + if (options.profileName) { + const returnedProfileId = createdSession['profileId']; + if (typeof returnedProfileId === 'string') { + await writeSteelProfile( + options.profileName, + returnedProfileId, + environment, + storedChromeProfile, + ); + } + } + return toSessionSummary(createdSession, mode, sessionName, environment); } @@ -647,6 +714,8 @@ export async function bootstrapBrowserPassthroughArgv( credentials: parsed.options.credentials || undefined, deadSessionBehavior: 'error', environment, + profileName: parsed.options.profileName || undefined, + updateProfile: parsed.options.updateProfile || undefined, }); if (!session.connectUrl) { diff --git a/source/utils/browser/lifecycle/api-client.ts b/source/utils/browser/lifecycle/api-client.ts index bbe0a03..ba164a8 100644 --- a/source/utils/browser/lifecycle/api-client.ts +++ b/source/utils/browser/lifecycle/api-client.ts @@ -354,6 +354,14 @@ export async function createSessionFromApi( payload['solveCaptcha'] = true; } + if (options.profileId) { + payload['profileId'] = options.profileId; + } + + if (options.persistProfile) { + payload['persistProfile'] = true; + } + if (options.namespace?.trim()) { payload['namespace'] = options.namespace.trim(); } diff --git a/source/utils/browser/lifecycle/bootstrap-flags.ts b/source/utils/browser/lifecycle/bootstrap-flags.ts index 7dc77a5..1942936 100644 --- a/source/utils/browser/lifecycle/bootstrap-flags.ts +++ b/source/utils/browser/lifecycle/bootstrap-flags.ts @@ -3,6 +3,7 @@ import { parsePositiveIntegerFlagValue, resolveExplicitApiUrl, } from './api-client.js'; +import {validateProfileName} from './profile-store.js'; import type {ParsedBootstrapOptions} from './types.js'; export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { @@ -21,6 +22,8 @@ export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { solveCaptcha: false, autoConnect: false, cdpTarget: null, + profileName: process.env['STEEL_PROFILE']?.trim() || null, + updateProfile: false, namespace: null, credentials: false, }; @@ -203,6 +206,39 @@ export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { ); } + if (argument === '--profile' || argument.startsWith('--profile=')) { + const value = + argument === '--profile' + ? browserArgv[index + 1] + : argument.slice('--profile='.length); + + if (!value) { + throw new BrowserAdapterError( + 'INVALID_BROWSER_ARGS', + 'Missing value for --profile.', + ); + } + + const name = value.trim(); + const validationError = validateProfileName(name); + if (validationError) { + throw new BrowserAdapterError('INVALID_BROWSER_ARGS', validationError); + } + + options.profileName = name; + + if (argument === '--profile') { + index++; + } + + continue; + } + + if (argument === '--update-profile') { + options.updateProfile = true; + continue; + } + if (argument === '--namespace' || argument.startsWith('--namespace=')) { const value = argument === '--namespace' diff --git a/source/utils/browser/lifecycle/profile-store.ts b/source/utils/browser/lifecycle/profile-store.ts new file mode 100644 index 0000000..609d4f8 --- /dev/null +++ b/source/utils/browser/lifecycle/profile-store.ts @@ -0,0 +1,130 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +type SteelProfileData = { + profileId: string; + chromeProfile?: string; +}; + +function getProfilesDirectory(environment: NodeJS.ProcessEnv): string { + const configDir = + environment.STEEL_CONFIG_DIR?.trim() || + path.join(os.homedir(), '.config', 'steel'); + return path.join(configDir, 'profiles'); +} + +function getProfilePath(name: string, environment: NodeJS.ProcessEnv): string { + return path.join(getProfilesDirectory(environment), `${name}.json`); +} + +export function validateProfileName(name: string): string | null { + if (!name.trim()) { + return 'Profile name cannot be empty.'; + } + + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) { + return `Invalid profile name "${name}". Use alphanumeric characters, hyphens, or underscores (must start with alphanumeric).`; + } + + return null; +} + +export async function readSteelProfile( + name: string, + environment: NodeJS.ProcessEnv, +): Promise<{profileId: string; chromeProfile?: string} | null> { + const filePath = getProfilePath(name, environment); + + try { + const contents = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(contents) as unknown; + + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as Record)['profileId'] === 'string' + ) { + const data = parsed as SteelProfileData; + return { + profileId: data.profileId, + chromeProfile: data.chromeProfile, + }; + } + + return null; + } catch { + return null; + } +} + +export async function writeSteelProfile( + name: string, + profileId: string, + environment: NodeJS.ProcessEnv, + chromeProfile?: string, +): Promise { + const profilesDir = getProfilesDirectory(environment); + await fs.mkdir(profilesDir, {recursive: true}); + + const filePath = getProfilePath(name, environment); + const data: SteelProfileData = {profileId, chromeProfile}; + + await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export async function listSteelProfiles( + environment: NodeJS.ProcessEnv, +): Promise> { + const profilesDir = getProfilesDirectory(environment); + + let entries: string[]; + try { + entries = await fs.readdir(profilesDir); + } catch { + return []; + } + + const profiles: Array<{name: string; profileId: string}> = []; + + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + + const name = entry.slice(0, -5); + const filePath = path.join(profilesDir, entry); + + try { + const contents = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(contents) as unknown; + + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as Record)['profileId'] === 'string' + ) { + profiles.push({ + name, + profileId: (parsed as SteelProfileData).profileId, + }); + } + } catch { + // Skip corrupt files + } + } + + return profiles; +} + +export async function deleteSteelProfile( + name: string, + environment: NodeJS.ProcessEnv, +): Promise { + const filePath = getProfilePath(name, environment); + + try { + await fs.unlink(filePath); + return true; + } catch { + return false; + } +} diff --git a/source/utils/browser/lifecycle/types.ts b/source/utils/browser/lifecycle/types.ts index 7e91a20..a152f90 100644 --- a/source/utils/browser/lifecycle/types.ts +++ b/source/utils/browser/lifecycle/types.ts @@ -21,6 +21,8 @@ export type StartSessionRequestOptions = { headless?: boolean; region?: string; solveCaptcha?: boolean; + profileId?: string; + persistProfile?: boolean; namespace?: string; credentials?: boolean; }; @@ -43,6 +45,8 @@ export type ParsedBootstrapOptions = { solveCaptcha: boolean; autoConnect: boolean; cdpTarget: string | null; + profileName: string | null; + updateProfile: boolean; namespace: string | null; credentials: boolean; }; @@ -72,6 +76,8 @@ export type StartBrowserSessionOptions = { credentials?: boolean; deadSessionBehavior?: DeadSessionBehavior; environment?: NodeJS.ProcessEnv; + profileName?: string; + updateProfile?: boolean; }; export type StopBrowserSessionOptions = { diff --git a/source/utils/browser/profile-porter.ts b/source/utils/browser/profile-porter.ts new file mode 100644 index 0000000..7ac8998 --- /dev/null +++ b/source/utils/browser/profile-porter.ts @@ -0,0 +1,394 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {exec, execSync} from 'node:child_process'; +import {zipSync} from 'fflate'; +import Database from 'better-sqlite3'; + +export type SyncProfileOptions = { + name: string; + chromeProfile?: string; +}; + +export type SyncProfileResult = { + profileId: string; + cookiesReencrypted: number; + zipBytes: number; +}; + +const CHROME_BASE_DIR = path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Google', + 'Chrome', +); + +const IV = Buffer.alloc(16, 0x20); // 16 space bytes + +const INCLUDE_ENTRIES = [ + 'Cookies', + 'Local Storage', + 'IndexedDB', + 'Preferences', + 'Bookmarks', + 'Favicons', + 'History', + 'Web Data', +]; + +const SKIP_NAMES = new Set([ + 'LOCK', + 'SingletonLock', + 'SingletonCookie', + 'SingletonSocket', +]); + +const SKIP_EXTS = new Set(['.log', '.pma']); + +function getKeychainPassphrase(): string { + return execSync( + 'security find-generic-password -w -s "Chrome Safe Storage"', + { + encoding: 'utf-8', + }, + ).trim(); +} + +function getKeychainPassphraseAsync(): Promise { + return new Promise((resolve, reject) => { + exec( + 'security find-generic-password -w -s "Chrome Safe Storage"', + {encoding: 'utf-8'}, + (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout.trim()); + } + }, + ); + }); +} + +function deriveKey(passphrase: string, iterations: number): Buffer { + return crypto.pbkdf2Sync(passphrase, 'saltysalt', iterations, 16, 'sha1'); +} + +function decryptCookie( + encryptedValue: Buffer, + key: Buffer, + hostKey: string, + metaVersion: number, +): string | null { + const prefix = encryptedValue.slice(0, 3).toString('ascii'); + if (prefix !== 'v10') return null; + + const decipher = crypto.createDecipheriv('aes-128-cbc', key, IV); + decipher.setAutoPadding(true); + + let plaintext: Buffer; + try { + plaintext = Buffer.concat([ + decipher.update(encryptedValue.slice(3)), + decipher.final(), + ]); + } catch { + return null; + } + + if (metaVersion >= 24 && plaintext.length >= 32) { + const expectedHash = crypto.createHash('sha256').update(hostKey).digest(); + if (expectedHash.equals(plaintext.slice(0, 32))) { + return plaintext.slice(32).toString('utf-8'); + } + } + + return plaintext.toString('utf-8'); +} + +function encryptCookie( + value: string, + key: Buffer, + hostKey: string, + metaVersion: number, +): Buffer { + let plaintext = Buffer.from(value, 'utf-8'); + + if (metaVersion >= 24) { + const domainHash = crypto.createHash('sha256').update(hostKey).digest(); + plaintext = Buffer.concat([domainHash, plaintext]); + } + + const cipher = crypto.createCipheriv('aes-128-cbc', key, IV); + cipher.setAutoPadding(true); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([Buffer.from('v10'), encrypted]); +} + +function makeReencryptedCookiesBuffer( + originalPath: string, + macosKey: Buffer, + peanutsKey: Buffer, +): {buffer: Buffer; converted: number} { + const tmpPath = path.join( + os.tmpdir(), + `steel-cookies-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); + fs.copyFileSync(originalPath, tmpPath); + + try { + const db = new Database(tmpPath); + try { + const metaVersion = Number( + ( + db.prepare("SELECT value FROM meta WHERE key='version'").get() as + | {value: string} + | undefined + )?.value ?? 0, + ); + + const rows = db + .prepare( + 'SELECT rowid, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 3', + ) + .all() as Array<{ + rowid: number; + host_key: string; + encrypted_value: Buffer; + }>; + + const update = db.prepare( + 'UPDATE cookies SET encrypted_value = ? WHERE rowid = ?', + ); + + let converted = 0; + + db.transaction(() => { + for (const row of rows) { + const plaintext = decryptCookie( + row.encrypted_value, + macosKey, + row.host_key, + metaVersion, + ); + if (plaintext === null) continue; + + const reencrypted = encryptCookie( + plaintext, + peanutsKey, + row.host_key, + metaVersion, + ); + update.run(reencrypted, row.rowid); + converted++; + } + })(); + + db.close(); + + const buffer = fs.readFileSync(tmpPath); + return {buffer, converted}; + } catch (error) { + db.close(); + throw error; + } + } finally { + try { + fs.unlinkSync(tmpPath); + } catch { + // best-effort cleanup + } + } +} + +function collectFiles( + dirPath: string, + baseDir: string, + files: Record = {}, +): Record { + if (!fs.existsSync(dirPath)) return files; + + for (const entry of fs.readdirSync(dirPath, {withFileTypes: true})) { + if (SKIP_NAMES.has(entry.name)) continue; + if (SKIP_EXTS.has(path.extname(entry.name))) continue; + + const fullPath = path.join(dirPath, entry.name); + const relPath = path.relative(baseDir, fullPath); + + if (entry.isDirectory()) { + collectFiles(fullPath, baseDir, files); + } else if (entry.isFile()) { + files[relPath] = new Uint8Array(fs.readFileSync(fullPath)); + } + } + + return files; +} + +export type ChromeProfile = { + dirName: string; + displayName: string; +}; + +export function findChromeProfiles(): ChromeProfile[] { + if (!fs.existsSync(CHROME_BASE_DIR)) return []; + return fs + .readdirSync(CHROME_BASE_DIR, {withFileTypes: true}) + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(name => fs.existsSync(path.join(CHROME_BASE_DIR, name, 'Cookies'))) + .map(dirName => ({ + dirName, + displayName: getChromeProfileDisplayName(dirName), + })); +} + +function getChromeProfileDisplayName(dirName: string): string { + try { + const prefsPath = path.join(CHROME_BASE_DIR, dirName, 'Preferences'); + const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8')) as { + profile?: {name?: string}; + account_info?: Array<{full_name?: string}>; + }; + const name = prefs?.profile?.name; + if (name && name !== dirName) return name; + const fullName = prefs?.account_info?.[0]?.full_name; + if (fullName) return fullName; + } catch { + console.error(`Error getting Chrome profile display name for ${dirName}`); + } + + return dirName; +} + +export function isChromeRunning(): boolean { + try { + execSync('pgrep -x "Google Chrome"', {stdio: 'ignore'}); + return true; + } catch { + return false; + } +} + +export type PackageResult = { + zipBuffer: Buffer; + cookiesReencrypted: number; +}; + +export async function packageChromeProfile( + chromeProfile: string, + onProgress?: (msg: string) => void, + onKeychainPrompt?: () => void, +): Promise { + const profileDir = path.join(CHROME_BASE_DIR, chromeProfile); + + if (!fs.existsSync(path.join(profileDir, 'Cookies'))) { + throw new Error( + `Chrome profile "${chromeProfile}" not found at ${profileDir}`, + ); + } + + onKeychainPrompt?.(); + // Let Ink render the message before the OS Keychain dialog appears + await new Promise(resolve => setTimeout(resolve, 100)); + const passphrase = await getKeychainPassphraseAsync(); + const macosKey = deriveKey(passphrase, 1003); + const peanutsKey = deriveKey('peanuts', 1); + + const files: Record = {}; + let cookiesReencrypted = 0; + + for (const entry of INCLUDE_ENTRIES) { + const fullPath = path.join(profileDir, entry); + if (!fs.existsSync(fullPath)) continue; + + const stat = fs.statSync(fullPath); + + if (stat.isFile()) { + if (entry === 'Cookies') { + onProgress?.('Re-encrypting Cookies...'); + const {buffer, converted} = makeReencryptedCookiesBuffer( + fullPath, + macosKey, + peanutsKey, + ); + files[`Default/${entry}`] = new Uint8Array(buffer); + cookiesReencrypted = converted; + } else { + files[`Default/${entry}`] = new Uint8Array(fs.readFileSync(fullPath)); + } + } else if (stat.isDirectory()) { + onProgress?.(`Collecting ${entry}/...`); + const dirFiles = collectFiles(fullPath, fullPath, {}); + for (const [relPath, data] of Object.entries(dirFiles)) { + files[`Default/${entry}/${relPath}`] = data; + } + } + } + + onProgress?.('Zipping...'); + const zipped = zipSync(files, {level: 6}); + return {zipBuffer: Buffer.from(zipped), cookiesReencrypted}; +} + +export async function updateProfileOnSteel( + profileId: string, + zipBuffer: Buffer, + apiKey: string, + apiBase: string, +): Promise { + const form = new FormData(); + form.append( + 'userDataDir', + new Blob([new Uint8Array(zipBuffer)], {type: 'application/zip'}), + 'userDataDir.zip', + ); + + const res = await fetch(`${apiBase}/profiles/${profileId}`, { + method: 'PATCH', + headers: {'Steel-Api-Key': apiKey}, + body: form, + }); + + if (!res.ok) { + const body = (await res.json()) as {message?: string}; + throw new Error( + `Profile update failed (${res.status}): ${body.message ?? JSON.stringify(body)}`, + ); + } +} + +export async function uploadProfileToSteel( + zipBuffer: Buffer, + apiKey: string, + apiBase: string, +): Promise { + const form = new FormData(); + form.append( + 'userDataDir', + new Blob([new Uint8Array(zipBuffer)], {type: 'application/zip'}), + 'userDataDir.zip', + ); + + const res = await fetch(`${apiBase}/profiles`, { + method: 'POST', + headers: {'Steel-Api-Key': apiKey}, + body: form, + }); + + const body = (await res.json()) as {id?: string; message?: string}; + + if (!res.ok) { + throw new Error( + `Profile upload failed (${res.status}): ${body.message ?? JSON.stringify(body)}`, + ); + } + + if (!body.id) { + throw new Error('Profile upload response missing id'); + } + + return body.id; +} diff --git a/tests/unit/browser-lifecycle.test.ts b/tests/unit/browser-lifecycle.test.ts index fc05fea..eca25bd 100644 --- a/tests/unit/browser-lifecycle.test.ts +++ b/tests/unit/browser-lifecycle.test.ts @@ -103,6 +103,8 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: false, autoConnect: false, cdpTarget: null, + profileName: null, + updateProfile: false, namespace: null, credentials: false, }); @@ -210,6 +212,8 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: false, autoConnect: false, cdpTarget: null, + profileName: null, + updateProfile: false, namespace: null, credentials: false, }); @@ -249,6 +253,8 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: true, autoConnect: false, cdpTarget: null, + profileName: null, + updateProfile: false, namespace: null, credentials: false, }); @@ -289,6 +295,8 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: true, autoConnect: false, cdpTarget: null, + profileName: null, + updateProfile: false, namespace: null, credentials: false, }); @@ -1373,3 +1381,344 @@ describe('browser lifecycle session contract', () => { } }); }); + +describe('browser lifecycle --profile flag parsing', () => { + test('parses --profile flag into profileName', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + 'https://steel.dev', + '--profile', + 'myapp', + ]); + + expect(parsed.options.profileName).toBe('myapp'); + expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('parses --profile= (equals form) into profileName', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + 'https://steel.dev', + '--profile=myapp', + ]); + + expect(parsed.options.profileName).toBe('myapp'); + expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('throws when --profile has no value', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + + expect(() => + lifecycle.parseBrowserPassthroughBootstrapFlags(['open', '--profile']), + ).toThrow('Missing value for --profile.'); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('throws when --profile value contains a path separator', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + + expect(() => + lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + '--profile', + '/tmp/my-profile', + ]), + ).toThrow('Invalid profile name'); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('parses --update-profile flag', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + 'https://steel.dev', + '--profile', + 'myapp', + '--update-profile', + ]); + + expect(parsed.options.profileName).toBe('myapp'); + expect(parsed.options.updateProfile).toBe(true); + expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('updateProfile defaults to false', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + '--profile', + 'myapp', + ]); + + expect(parsed.options.updateProfile).toBe(false); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('profileName defaults to null when STEEL_PROFILE is not set', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + delete process.env['STEEL_PROFILE']; + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + 'https://steel.dev', + ]); + + expect(parsed.options.profileName).toBeNull(); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); +}); + +describe('browser lifecycle session profile contract', () => { + test('creates new session with persistProfile when no profile file exists', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + fetchMock.mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-new-profile', + status: 'live', + profileId: 'returned-profile-uuid', + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + const session = await lifecycle.startBrowserSession({ + profileName: 'myapp', + updateProfile: true, + environment: { + STEEL_API_KEY: 'env-api-key', + STEEL_CONFIG_DIR: configDirectory, + }, + }); + + expect(session.id).toBe('session-new-profile'); + + // Should pass persistProfile: true but no profileId + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body['persistProfile']).toBe(true); + expect(body['profileId']).toBeUndefined(); + + // Should write the returned profileId to profiles/myapp.json + const profileFile = path.join(configDirectory, 'profiles', 'myapp.json'); + expect(fs.existsSync(profileFile)).toBe(true); + const written = JSON.parse(fs.readFileSync(profileFile, 'utf-8')) as { + profileId: string; + }; + expect(written.profileId).toBe('returned-profile-uuid'); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('reuses stored profileId from profile file on subsequent start', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + // Pre-seed profiles/myapp.json with an existing profileId + const profilesDir = path.join(configDirectory, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'myapp.json'), + JSON.stringify({profileId: 'existing-profile-uuid'}), + 'utf-8', + ); + + fetchMock.mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-with-profile', + status: 'live', + profileId: 'existing-profile-uuid', + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + const session = await lifecycle.startBrowserSession({ + profileName: 'myapp', + updateProfile: true, + environment: { + STEEL_API_KEY: 'env-api-key', + STEEL_CONFIG_DIR: configDirectory, + }, + }); + + expect(session.id).toBe('session-with-profile'); + + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body['profileId']).toBe('existing-profile-uuid'); + expect(body['persistProfile']).toBe(true); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('recovers from expired profileId: warns and retries without profileId', async () => { + const configDirectory = createTempConfigDirectory(); + const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const profilesDir = path.join(configDirectory, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'myapp.json'), + JSON.stringify({profileId: 'expired-profile-uuid'}), + 'utf-8', + ); + + // First call: 404 for expired profileId + fetchMock + .mockResolvedValueOnce( + createJsonResponse(404, { + message: "Profile with ID 'expired-profile-uuid' not found", + }), + ) + // Second call: success with new profileId + .mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-recovered', + status: 'live', + profileId: 'new-profile-uuid', + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + const session = await lifecycle.startBrowserSession({ + profileName: 'myapp', + updateProfile: true, + environment: { + STEEL_API_KEY: 'env-api-key', + STEEL_CONFIG_DIR: configDirectory, + }, + }); + + expect(session.id).toBe('session-recovered'); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Profile not found'), + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const retryBody = JSON.parse( + fetchMock.mock.calls[1]?.[1]?.body as string, + ) as Record; + expect(retryBody['profileId']).toBeUndefined(); + expect(retryBody['persistProfile']).toBe(true); + + // New profileId should be written to profiles/myapp.json + const written = JSON.parse( + fs.readFileSync( + path.join(configDirectory, 'profiles', 'myapp.json'), + 'utf-8', + ), + ) as {profileId: string}; + expect(written.profileId).toBe('new-profile-uuid'); + } finally { + warnSpy.mockRestore(); + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('does not write profile file when API does not return profileId', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + fetchMock.mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-no-profile-returned', + status: 'live', + // no profileId field + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + await lifecycle.startBrowserSession({ + profileName: 'myapp', + environment: { + STEEL_API_KEY: 'env-api-key', + STEEL_CONFIG_DIR: configDirectory, + }, + }); + + expect( + fs.existsSync(path.join(configDirectory, 'profiles', 'myapp.json')), + ).toBe(false); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('bootstrap passthrough passes profileName from --profile flag', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + fetchMock.mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-bootstrap-profile', + status: 'live', + profileId: 'bootstrap-profile-uuid', + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + const result = await lifecycle.bootstrapBrowserPassthroughArgv( + ['open', 'https://steel.dev', '--profile', 'myapp'], + {STEEL_API_KEY: 'env-api-key', STEEL_CONFIG_DIR: configDirectory}, + ); + + expect(result.argv[0]).toBe('open'); + expect(result.argv).toContain('--cdp'); + + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + // --profile without --update-profile should not persist + expect(body['persistProfile']).toBeFalsy(); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); +}); diff --git a/tests/unit/browser-profile-store.test.ts b/tests/unit/browser-profile-store.test.ts new file mode 100644 index 0000000..1c60dbf --- /dev/null +++ b/tests/unit/browser-profile-store.test.ts @@ -0,0 +1,398 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {describe, test, expect} from 'vitest'; +import { + readSteelProfile, + writeSteelProfile, + validateProfileName, + listSteelProfiles, + deleteSteelProfile, +} from '../../source/utils/browser/lifecycle/profile-store'; + +function createTempConfigDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'steel-profile-store-test-')); +} + +describe('validateProfileName', () => { + test('accepts simple names', () => { + expect(validateProfileName('myapp')).toBeNull(); + expect(validateProfileName('my-app')).toBeNull(); + expect(validateProfileName('my_app')).toBeNull(); + expect(validateProfileName('MyApp123')).toBeNull(); + }); + + test('rejects empty or whitespace-only names', () => { + expect(validateProfileName('')).not.toBeNull(); + expect(validateProfileName(' ')).not.toBeNull(); + }); + + test('rejects names containing path separators', () => { + expect(validateProfileName('my/app')).not.toBeNull(); + expect(validateProfileName('my\\app')).not.toBeNull(); + expect(validateProfileName('../etc/passwd')).not.toBeNull(); + }); + + test('rejects path traversal names', () => { + expect(validateProfileName('.')).not.toBeNull(); + expect(validateProfileName('..')).not.toBeNull(); + expect(validateProfileName('.hidden')).not.toBeNull(); + }); + + test('rejects names with dots', () => { + expect(validateProfileName('my.app')).not.toBeNull(); + }); +}); + +describe('readSteelProfile', () => { + test('returns null when profile file does not exist', async () => { + const configDir = createTempConfigDir(); + + try { + const result = await readSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBeNull(); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('reads profileId from existing profile file', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'myapp.json'), + JSON.stringify({profileId: 'uuid-abc-123'}), + 'utf-8', + ); + + const result = await readSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toEqual({profileId: 'uuid-abc-123'}); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('returns null when profile file contains invalid JSON', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'myapp.json'), + 'not-json', + 'utf-8', + ); + + const result = await readSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBeNull(); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('returns null when profile file has no profileId field', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'myapp.json'), + JSON.stringify({something: 'else'}), + 'utf-8', + ); + + const result = await readSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBeNull(); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('uses default config dir (~/.config/steel) when STEEL_CONFIG_DIR is not set', async () => { + // Just verify it doesn't throw and returns null for a non-existent profile + const result = await readSteelProfile( + `nonexistent-profile-${Date.now()}`, + {}, + ); + expect(result).toBeNull(); + }); +}); + +describe('writeSteelProfile', () => { + test('creates profiles directory and writes profile file', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'new-profile-uuid', { + STEEL_CONFIG_DIR: configDir, + }); + + const filePath = path.join(configDir, 'profiles', 'myapp.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const contents = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + profileId: string; + }; + expect(contents.profileId).toBe('new-profile-uuid'); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('does not write updatedAt (no timestamp noise in file)', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'some-uuid', { + STEEL_CONFIG_DIR: configDir, + }); + + const contents = JSON.parse( + fs.readFileSync( + path.join(configDir, 'profiles', 'myapp.json'), + 'utf-8', + ), + ) as Record; + expect(Object.keys(contents)).toEqual(['profileId']); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('overwrites existing profile', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'first-uuid', { + STEEL_CONFIG_DIR: configDir, + }); + await writeSteelProfile('myapp', 'second-uuid', { + STEEL_CONFIG_DIR: configDir, + }); + + const contents = JSON.parse( + fs.readFileSync( + path.join(configDir, 'profiles', 'myapp.json'), + 'utf-8', + ), + ) as {profileId: string}; + expect(contents.profileId).toBe('second-uuid'); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('different profile names are stored in separate files', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('app-a', 'uuid-a', {STEEL_CONFIG_DIR: configDir}); + await writeSteelProfile('app-b', 'uuid-b', {STEEL_CONFIG_DIR: configDir}); + + const profilesDir = path.join(configDir, 'profiles'); + const a = JSON.parse( + fs.readFileSync(path.join(profilesDir, 'app-a.json'), 'utf-8'), + ) as {profileId: string}; + const b = JSON.parse( + fs.readFileSync(path.join(profilesDir, 'app-b.json'), 'utf-8'), + ) as {profileId: string}; + + expect(a.profileId).toBe('uuid-a'); + expect(b.profileId).toBe('uuid-b'); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); +}); + +describe('listSteelProfiles', () => { + test('returns empty array when profiles directory does not exist', async () => { + const configDir = createTempConfigDir(); + + try { + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('returns empty array when profiles directory is empty', async () => { + const configDir = createTempConfigDir(); + + try { + fs.mkdirSync(path.join(configDir, 'profiles'), {recursive: true}); + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('lists all valid profiles', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('app-a', 'uuid-a', {STEEL_CONFIG_DIR: configDir}); + await writeSteelProfile('app-b', 'uuid-b', {STEEL_CONFIG_DIR: configDir}); + + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + const sorted = result.sort((a, b) => a.name.localeCompare(b.name)); + + expect(sorted).toEqual([ + {name: 'app-a', profileId: 'uuid-a'}, + {name: 'app-b', profileId: 'uuid-b'}, + ]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('skips corrupt (invalid JSON) files', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'corrupt.json'), + 'not-json', + 'utf-8', + ); + await writeSteelProfile('good', 'uuid-good', { + STEEL_CONFIG_DIR: configDir, + }); + + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([{name: 'good', profileId: 'uuid-good'}]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('skips files missing profileId field', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'no-id.json'), + JSON.stringify({something: 'else'}), + 'utf-8', + ); + await writeSteelProfile('valid', 'uuid-valid', { + STEEL_CONFIG_DIR: configDir, + }); + + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([{name: 'valid', profileId: 'uuid-valid'}]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('ignores non-.json files', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'readme.txt'), + 'ignore me', + 'utf-8', + ); + await writeSteelProfile('myapp', 'uuid-myapp', { + STEEL_CONFIG_DIR: configDir, + }); + + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([{name: 'myapp', profileId: 'uuid-myapp'}]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); +}); + +describe('deleteSteelProfile', () => { + test('returns false when profile does not exist', async () => { + const configDir = createTempConfigDir(); + + try { + const result = await deleteSteelProfile('nonexistent', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBe(false); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('deletes the profile file and returns true', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'uuid-123', { + STEEL_CONFIG_DIR: configDir, + }); + const filePath = path.join(configDir, 'profiles', 'myapp.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const result = await deleteSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBe(true); + expect(fs.existsSync(filePath)).toBe(false); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('does not affect other profiles when deleting one', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('app-a', 'uuid-a', {STEEL_CONFIG_DIR: configDir}); + await writeSteelProfile('app-b', 'uuid-b', {STEEL_CONFIG_DIR: configDir}); + + await deleteSteelProfile('app-a', {STEEL_CONFIG_DIR: configDir}); + + const remaining = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(remaining).toEqual([{name: 'app-b', profileId: 'uuid-b'}]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('returns false on second delete of same profile', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'uuid-123', { + STEEL_CONFIG_DIR: configDir, + }); + await deleteSteelProfile('myapp', {STEEL_CONFIG_DIR: configDir}); + + const result = await deleteSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBe(false); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); +});