@vostride/agent-qa-plugin-steel 0.0.0-stage → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,105 @@
1
+ # Functional Source License, Version 1.1, ALv2 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-ALv2
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Pranshu Chittora
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the Apache License, Version 2.0 that is effective on the second anniversary of
91
+ the date we make the Software available. On or after that date, you may use the
92
+ Software under the Apache License, Version 2.0, in which case the following
93
+ will apply:
94
+
95
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
+ this file except in compliance with the License.
97
+
98
+ You may obtain a copy of the License at
99
+
100
+ http://www.apache.org/licenses/LICENSE-2.0
101
+
102
+ Unless required by applicable law or agreed to in writing, software distributed
103
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
+ specific language governing permissions and limitations under the License.
package/NOTICE.md ADDED
@@ -0,0 +1,11 @@
1
+ # agent-qa Notice
2
+
3
+ agent-qa is provided by Pranshu Chittora.
4
+
5
+ Copyright 2026 Pranshu Chittora
6
+
7
+ agent-qa is licensed under the Functional Source License, Version 1.1, ALv2 Future License (FSL-1.1-ALv2). See `LICENSE.md` for the full license terms.
8
+
9
+ Under the Grant of Future License in `LICENSE.md`, each version of the Software is also granted under the Apache License, Version 2.0 effective on the second anniversary of the date that version was made available.
10
+
11
+ Third-party dependencies are distributed under their own license terms. This notice covers agent-qa itself and does not replace or modify notices required by third-party dependency owners.
package/README.md CHANGED
@@ -1,3 +1,193 @@
1
- # Temporary Holding Version
1
+ # @vostride/agent-qa-plugin-steel
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The agent-qa plugin for [Steel](https://steel.dev) cloud browsers. Before a web test connects, it
4
+ creates a Steel session and answers agent-qa with the session's CDP URL, and after the test it
5
+ releases the session. Steel runs Chromium only.
6
+
7
+ ## Install
8
+
9
+ Install the plugin at exactly the version of agent-qa the project runs:
10
+
11
+ ```bash
12
+ npm install --save-dev --save-exact @vostride/agent-qa-plugin-steel@<agent-qa version>
13
+ ```
14
+
15
+ `agent-qa plugins list` prints this line, with the version filled in, when the package is missing
16
+ or its version differs from agent-qa's.
17
+
18
+ ## Configure
19
+
20
+ ```yaml
21
+ plugins:
22
+ - name: steel
23
+ package: "@vostride/agent-qa-plugin-steel"
24
+ options:
25
+ apiKey: "{{secret:STEEL_API_KEY}}"
26
+
27
+ registry:
28
+ remotes:
29
+ - name: steel
30
+ plugin: steel
31
+ settings:
32
+ idleMinutes: 5
33
+ ```
34
+
35
+ A test runs there with `use.browser.remote: steel` in the config, a suite or the test.
36
+
37
+ ### Options
38
+
39
+ Options belong to the `plugins` entry and describe the account.
40
+
41
+ | Option | Default | Meaning |
42
+ | -------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------- |
43
+ | `apiKey` | - | The Steel API key, a credential. Write it as a `{{secret:<name>}}` reference. Leave it out for a self-hosted steel-browser. |
44
+ | `apiUrl` | `https://api.steel.dev` | Steel's API. It must use `https://` with no user or password, unless it points at this machine. |
45
+
46
+ The key is sent as the `steel-api-key` header and in the connect URL agent-qa receives, which
47
+ agent-qa masks. It never appears in a message, a log or a check line.
48
+
49
+ ### Settings
50
+
51
+ Settings belong to each remote and describe one place.
52
+
53
+ | Setting | Default | Meaning |
54
+ | ---------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
55
+ | `timeoutMinutes` | `1440` | Minutes before Steel ends the session (1 to 1440), sent as `timeout` in milliseconds. Your Steel plan may cap it lower. |
56
+ | `idleMinutes` | `5` | Minutes with no CDP command before Steel ends the session (1 to 60), sent as `inactivityTimeout` in milliseconds. |
57
+ | `solveCaptcha` | `false` | Steel's CAPTCHA solving, sent as `solveCaptcha`. |
58
+ | `blockAds` | `false` | Steel's ad blocking, sent as `blockAds`. |
59
+ | `fingerprint` | `false` | Steel's browser fingerprint injection, sent as `stealthConfig.skipFingerprintInjection`, the opposite value. |
60
+ | `proxy` | `false` | Steel's proxy: `true`, or Steel's proxy object. Sent as `useProxy`. |
61
+ | `proxyUrl` | - | Your own proxy, `http(s)://user:password@host:port`, a credential. Sent as `proxyUrl` instead of Steel's proxy. |
62
+ | `dimensions` | - | The browser window, `{ width, height }` in pixels, sent as `dimensions`. |
63
+ | `region` | - | The Steel region, for example `us-east`, sent as `region`. |
64
+ | `profile` | - | A Steel profile id, loaded read-only and sent as `profileId`. Needs `context: reuse`. |
65
+ | `extensions` | - | Steel extension ids loaded into the browser, sent as `extensionIds`. |
66
+ | `extra` | - | Any other Steel request field, merged last. |
67
+
68
+ `agent-qa plugins describe steel` prints the same tables from the schemas the package ships, and
69
+ `--json` prints them as JSON.
70
+
71
+ ## How the request body is built
72
+
73
+ The create request (`POST /v1/sessions`) is built in this order, each part overriding the one
74
+ before:
75
+
76
+ 1. `sessionId`, a UUID the plugin chooses, so it can release the session even when the create
77
+ answer never arrives;
78
+ 2. the lifetimes, `timeout` and `inactivityTimeout`, from `timeoutMinutes` and `idleMinutes`;
79
+ 3. the features that change the page: CAPTCHA solving, ad blocking, Steel's fingerprint injection
80
+ and Steel's proxies. Each is sent off unless a setting turns it on, because a test should see
81
+ the app the way its users do. Steel injects fingerprints unless it is told not to, so the plugin
82
+ always tells it, with `stealthConfig: { skipFingerprintInjection: true }` by default;
83
+ 4. the other settings above, in Steel's field names. With `proxyUrl` the plugin sends your proxy
84
+ and no `useProxy`. A remote that sets both `proxyUrl` and `proxy` is refused with path
85
+ `settings.proxyUrl`;
86
+ 5. `extra`, last, so a pass-through key overrides both a default and a setting. It cannot set
87
+ `sessionId`, which the plugin releases the session by; a remote whose `extra` sets it is refused
88
+ with path `settings.extra.sessionId`.
89
+
90
+ For example, `settings: { extra: { solveCaptcha: true, headless: true } }` turns CAPTCHA solving on
91
+ and asks for a headless browser.
92
+
93
+ ## Lifetime
94
+
95
+ By default the plugin asks for a 1440-minute session that ends after 5 idle minutes.
96
+
97
+ - Steel's plans cap the session lifetime: Launch at 15 minutes, Scale at 60 minutes and Enterprise
98
+ at up to 1440 minutes. When Steel refuses the create with `400` or `422` while more than 15
99
+ minutes were asked for, and its reason mentions a timeout, lifetime, duration, minutes, plan or
100
+ limit, or gives no reason, the failure line ends
101
+ `if your Steel plan caps sessions below <n> minutes, set settings.timeoutMinutes to the cap`.
102
+ Set `timeoutMinutes` to your plan's cap. A refusal whose reason is about another field does not
103
+ name `timeoutMinutes`.
104
+ - When Steel grants a shorter session than was asked for, the plugin writes
105
+ `Steel granted a <n>-minute session; <m> minutes were asked for` to its log.
106
+ - Any CDP command resets the idle time. agent-qa's heartbeat keeps a running test from going idle.
107
+ - A session left behind by a killed job, where agent-qa and the plugin die together, ends within
108
+ the idle time, so it stops billing within minutes.
109
+
110
+ agent-qa releases every session itself after the test; the lifetimes only bound a session that
111
+ agent-qa could not release.
112
+
113
+ ## Recording
114
+
115
+ Steel records every session and keeps the replay on its dashboard, behind its login. agent-qa's
116
+ masking does not reach that replay, so a secret a test types may show in it. Steel offers no switch
117
+ to turn recording off, so the plugin has no `recording` setting, and a remote that sets one is
118
+ refused.
119
+
120
+ ## Profiles
121
+
122
+ `profile` loads a Steel profile by id, and the plugin never asks Steel to persist it, so nothing a
123
+ test does is saved back. A profile lives in the browser's first context, so the remote needs
124
+ `context: reuse`. Without it, the remote is refused with kind `refused` and path
125
+ `settings.profile`.
126
+
127
+ ## Self-hosted steel-browser
128
+
129
+ The plugin also drives a self-hosted [steel-browser](https://github.com/steel-dev/steel-browser):
130
+
131
+ ```yaml
132
+ plugins:
133
+ - name: steel-local
134
+ package: "@vostride/agent-qa-plugin-steel"
135
+ options:
136
+ apiUrl: "http://localhost:3000"
137
+
138
+ registry:
139
+ remotes:
140
+ - name: steel-local
141
+ plugin: steel-local
142
+
143
+ use:
144
+ workers: 1
145
+ ```
146
+
147
+ - Point `apiUrl` at it. Plain `http://` is allowed only for this machine.
148
+ - Leave out `apiKey`; the plugin then sends no key.
149
+ - Set `use.workers: 1`. A steel-browser serves one session at a time, and releasing a session ends
150
+ whatever session is active, so two tests at once would end each other's browser.
151
+ - Its session page is plain `http://`, so the plugin returns no link for it.
152
+
153
+ ## Failures
154
+
155
+ | Steel answer | Kind |
156
+ | --------------------- | ------------- |
157
+ | no answer | `unreachable` |
158
+ | `401` | `auth` |
159
+ | `429` | `capacity` |
160
+ | any other non-success | `refused` |
161
+
162
+ Each failure is one line naming the status, the request and Steel's message, with `Retry-After` on
163
+ a `429`. A create refused with `400` or `422` may also name `settings.timeoutMinutes`, as above.
164
+
165
+ A create Steel refuses with a `4xx` made no session, so it carries no handle and nothing is
166
+ released. Every other create failure (no answer, a `5xx`, or an answer the plugin cannot read)
167
+ carries the session's id, so agent-qa releases whatever Steel may have made.
168
+
169
+ On release, `2xx` and `404` both count as released. On any other answer the plugin reads the
170
+ session back, and counts it released when Steel no longer has it or no longer reports it live.
171
+
172
+ ## Links and the debug URL
173
+
174
+ The session link is the session's page in Steel's dashboard, which sits behind Steel's login and
175
+ shows the replay. Steel's debug URL lets anyone who has it watch and drive the session, so it is a
176
+ credential: the plugin adds it, with the connect URL, to the values agent-qa masks, and never
177
+ returns it as the link.
178
+
179
+ ## Account check
180
+
181
+ `agent-qa doctor` runs the plugin's `check`, which lists sessions with `GET /v1/sessions` and opens
182
+ none:
183
+
184
+ - An accepted key reads `the API key was accepted`.
185
+ - Without `apiKey`, a steel-browser that answers reads `Steel answered without an API key`, and a
186
+ Steel that asks for a key warns `Steel asked for an API key; set options.apiKey`.
187
+ - A refused key warns `the API key was refused`.
188
+ - Any other failure warns with its one line, for example
189
+ `Steel could not be reached at <apiUrl>`.
190
+
191
+ ## License
192
+
193
+ FSL-1.1-ALv2. See `LICENSE.md` and `NOTICE.md` in the package.
package/dist/main.js ADDED
@@ -0,0 +1,7 @@
1
+ import { t as plugin_default } from "./plugin-C8AoyB8z.js";
2
+ import { runPlugin } from "@vostride/agent-qa-plugin-kit";
3
+ // src/main.ts
4
+ runPlugin(plugin_default);
5
+ export {};
6
+
7
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.js","names":["plugin"],"sources":["../src/main.ts"],"sourcesContent":["import { runPlugin } from '@vostride/agent-qa-plugin-kit'\nimport plugin from './plugin.js'\n\nrunPlugin(plugin)\n"],"mappings":";;;AAGA,UAAUA,cAAM"}
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "apiKey": {
6
+ "type": "string",
7
+ "credential": true,
8
+ "description": "The Steel API key; leave it out for a self-hosted steel-browser"
9
+ },
10
+ "apiUrl": {
11
+ "default": "https://api.steel.dev",
12
+ "description": "Steel's API, https:// unless it points at this machine",
13
+ "type": "string",
14
+ "format": "uri"
15
+ }
16
+ },
17
+ "additionalProperties": false
18
+ }
@@ -0,0 +1,256 @@
1
+ import { PluginError, checkEndpoints, createVendorApi, credential, definePlugin, z } from "@vostride/agent-qa-plugin-kit";
2
+ import { randomUUID } from "node:crypto";
3
+ // src/steel-api.ts
4
+ const SteelSessionSchema = z.object({
5
+ id: z.string().min(1),
6
+ websocketUrl: z.string().min(1),
7
+ debugUrl: z.string().min(1).optional(),
8
+ sessionViewerUrl: z.string().min(1).optional(),
9
+ timeout: z.number().optional()
10
+ });
11
+ const SessionStatusSchema = z.object({ status: z.string() });
12
+ var SteelCreateFailure = class extends PluginError {
13
+ status;
14
+ constructor(failure, status) {
15
+ super(failure.message, { kind: failure.kind });
16
+ this.status = status;
17
+ }
18
+ };
19
+ function createSteelApi(input) {
20
+ const api = createVendorApi({
21
+ vendor: "Steel",
22
+ baseUrl: input.apiUrl,
23
+ headers: input.apiKey === void 0 ? {} : { "steel-api-key": input.apiKey }
24
+ });
25
+ const sessionPath = (id) => `v1/sessions/${encodeURIComponent(id)}`;
26
+ async function session(id, signal) {
27
+ const response = await api.send("GET", sessionPath(id), {
28
+ headers: { accept: "application/json" },
29
+ signal
30
+ });
31
+ if (response.status === 404) {
32
+ await response.body?.cancel();
33
+ return;
34
+ }
35
+ if (!response.ok) throw await api.failure(response, "GET /v1/sessions/{id}");
36
+ return api.json(response);
37
+ }
38
+ async function released(id, signal) {
39
+ let current;
40
+ try {
41
+ current = await session(id, signal);
42
+ } catch {
43
+ return false;
44
+ }
45
+ if (current === void 0) return true;
46
+ const parsed = SessionStatusSchema.safeParse(current);
47
+ return parsed.success && parsed.data.status !== "live";
48
+ }
49
+ return {
50
+ async createSession(body) {
51
+ const response = await api.send("POST", "v1/sessions", {
52
+ headers: {
53
+ "content-type": "application/json",
54
+ accept: "application/json"
55
+ },
56
+ body: JSON.stringify(body)
57
+ });
58
+ if (!response.ok) throw new SteelCreateFailure(await api.failure(response, "POST /v1/sessions"), response.status);
59
+ return api.json(response);
60
+ },
61
+ async release(id, signal) {
62
+ const response = await api.send("POST", `${sessionPath(id)}/release`, {
63
+ headers: { accept: "application/json" },
64
+ signal
65
+ });
66
+ if (response.ok || response.status === 404) {
67
+ await response.body?.cancel();
68
+ return;
69
+ }
70
+ const failure = await api.failure(response, "POST /v1/sessions/{id}/release");
71
+ if (await released(id, signal)) return;
72
+ throw failure;
73
+ },
74
+ session,
75
+ async listSessions(signal) {
76
+ const response = await api.send("GET", "v1/sessions?limit=1", {
77
+ headers: { accept: "application/json" },
78
+ signal
79
+ });
80
+ if (!response.ok) throw await api.failure(response, "GET /v1/sessions");
81
+ await response.body?.cancel();
82
+ }
83
+ };
84
+ }
85
+ // src/plugin.ts
86
+ const STEEL_API_URL = "https://api.steel.dev";
87
+ const MINUTE_MS = 6e4;
88
+ const LIFETIME_REFUSALS = [400, 422];
89
+ const SMALLEST_PLAN_CAP_MINUTES = 15;
90
+ const LIFETIME_WORDS = /timeout|lifetime|duration|minute|plan|limit/i;
91
+ const options = z.strictObject({
92
+ apiKey: credential("The Steel API key; leave it out for a self-hosted steel-browser").optional(),
93
+ apiUrl: z.url().default(STEEL_API_URL).describe("Steel's API, https:// unless it points at this machine")
94
+ });
95
+ const settings = z.strictObject({
96
+ timeoutMinutes: z.int().min(1).max(1440).default(1440).describe("Minutes before Steel ends the session; your Steel plan may cap it lower"),
97
+ idleMinutes: z.int().min(1).max(60).default(5).describe("Minutes with no CDP command before Steel ends the session"),
98
+ solveCaptcha: z.boolean().default(false).describe("Steel's CAPTCHA solving"),
99
+ blockAds: z.boolean().default(false).describe("Steel's ad blocking"),
100
+ fingerprint: z.boolean().default(false).describe("Steel's browser fingerprint injection, which changes what pages see"),
101
+ proxy: z.union([z.boolean(), z.record(z.string(), z.unknown())]).default(false).describe("Steel's proxy, true or Steel's proxy object, sent as useProxy"),
102
+ proxyUrl: credential("Your own proxy, http(s)://user:password@host:port, sent instead of Steel's").optional(),
103
+ dimensions: z.strictObject({
104
+ width: z.int().positive(),
105
+ height: z.int().positive()
106
+ }).optional().describe("The browser window size"),
107
+ region: z.string().min(1).optional().describe("The Steel region, for example us-east"),
108
+ profile: z.string().min(1).optional().describe("A Steel profile id loaded read-only; needs context: reuse"),
109
+ extensions: z.array(z.string().min(1)).min(1).optional().describe("Steel extension ids loaded into the browser"),
110
+ extra: z.record(z.string(), z.unknown()).optional().describe("Fields merged last into the session create request, overriding the rest")
111
+ });
112
+ function checkSettings(input, session) {
113
+ if (input.profile !== void 0 && (session.platform !== "web" || session.context !== "reuse")) throw new PluginError("profile loads only into a reused context; set context: reuse on the remote.", {
114
+ kind: "refused",
115
+ path: "settings.profile"
116
+ });
117
+ if (input.extra !== void 0 && Object.hasOwn(input.extra, "sessionId")) throw new PluginError("extra cannot set sessionId; the plugin chooses it to release the session.", {
118
+ kind: "refused",
119
+ path: "settings.extra.sessionId"
120
+ });
121
+ if (input.proxyUrl !== void 0 && input.proxy !== false) throw new PluginError("proxy and proxyUrl both choose a proxy; set one of them.", {
122
+ kind: "refused",
123
+ path: "settings.proxyUrl"
124
+ });
125
+ }
126
+ function createBody(sessionId, input) {
127
+ return {
128
+ sessionId,
129
+ timeout: input.timeoutMinutes * MINUTE_MS,
130
+ inactivityTimeout: input.idleMinutes * MINUTE_MS,
131
+ solveCaptcha: input.solveCaptcha,
132
+ blockAds: input.blockAds,
133
+ ...input.proxyUrl === void 0 ? { useProxy: input.proxy } : {},
134
+ stealthConfig: { skipFingerprintInjection: !input.fingerprint },
135
+ ...input.proxyUrl === void 0 ? {} : { proxyUrl: input.proxyUrl },
136
+ ...input.dimensions === void 0 ? {} : { dimensions: input.dimensions },
137
+ ...input.region === void 0 ? {} : { region: input.region },
138
+ ...input.profile === void 0 ? {} : { profileId: input.profile },
139
+ ...input.extensions === void 0 ? {} : { extensionIds: input.extensions },
140
+ ...input.extra
141
+ };
142
+ }
143
+ function mayHaveCreated(error) {
144
+ return !(error instanceof SteelCreateFailure) || error.status >= 500;
145
+ }
146
+ function lifetimeMayBeRefused(error, timeoutMinutes) {
147
+ if (!(error instanceof SteelCreateFailure) || !LIFETIME_REFUSALS.includes(error.status)) return false;
148
+ if (timeoutMinutes <= SMALLEST_PLAN_CAP_MINUTES) return false;
149
+ const reason = error.message.split(": ").slice(1).join(": ");
150
+ return reason === "" || LIFETIME_WORDS.test(reason);
151
+ }
152
+ function createFailure(error, sessionId, timeoutMinutes) {
153
+ const message = error instanceof Error ? error.message : String(error);
154
+ const kind = error instanceof PluginError ? error.kind : "refused";
155
+ const text = lifetimeMayBeRefused(error, timeoutMinutes) ? `${message.replace(/[\s.;,]+$/, "")}; if your Steel plan caps sessions below ${String(timeoutMinutes)} minutes, set settings.timeoutMinutes to the cap` : message;
156
+ return new PluginError(text, {
157
+ kind,
158
+ ...mayHaveCreated(error) ? { handle: { sessionId } } : {}
159
+ });
160
+ }
161
+ function minutesOf(ms) {
162
+ return String(Number((ms / MINUTE_MS).toFixed(1)));
163
+ }
164
+ function noteGrantedLifetime(granted, asked) {
165
+ if (granted === void 0 || typeof asked !== "number" || granted >= asked) return;
166
+ process.stderr.write(`Steel granted a ${minutesOf(granted)}-minute session; ${minutesOf(asked)} minutes were asked for\n`);
167
+ }
168
+ function viewerLink(url) {
169
+ if (url === void 0) return void 0;
170
+ return URL.parse(url)?.protocol === "https:" ? url : void 0;
171
+ }
172
+ const HandleSchema = z.object({ sessionId: z.string().min(1) });
173
+ function hasId(answer) {
174
+ if (typeof answer !== "object" || answer === null || !("id" in answer)) return false;
175
+ return typeof answer.id === "string" && answer.id !== "";
176
+ }
177
+ function refusedKey(error) {
178
+ return error instanceof PluginError && error.kind === "auth";
179
+ }
180
+ function failureLine(error) {
181
+ return error instanceof Error ? error.message : String(error);
182
+ }
183
+ var plugin_default = definePlugin({
184
+ options,
185
+ settings,
186
+ serves: ["cdp"],
187
+ skill: "./skill",
188
+ initialize({ options }) {
189
+ checkEndpoints(options, [{
190
+ path: "apiUrl",
191
+ scheme: "https"
192
+ }]);
193
+ },
194
+ async check({ options }, ctx) {
195
+ try {
196
+ await createSteelApi(options).listSessions(ctx.signal);
197
+ } catch (error) {
198
+ if (!refusedKey(error)) return {
199
+ state: "warn",
200
+ lines: [failureLine(error)]
201
+ };
202
+ return {
203
+ state: "warn",
204
+ lines: [options.apiKey === void 0 ? "Steel asked for an API key; set options.apiKey" : "the API key was refused"]
205
+ };
206
+ }
207
+ return {
208
+ state: "ok",
209
+ lines: [options.apiKey === void 0 ? "Steel answered without an API key" : "the API key was accepted"]
210
+ };
211
+ },
212
+ async acquire({ options, settings, session }) {
213
+ if (session.platform !== "web" || session.engine !== "chromium") {
214
+ const what = session.platform === "web" ? session.engine : session.platform;
215
+ throw new PluginError(`Steel runs Chromium web sessions only, not ${what}.`, { kind: "refused" });
216
+ }
217
+ checkSettings(settings, session);
218
+ const api = createSteelApi(options);
219
+ const sessionId = randomUUID();
220
+ const body = createBody(sessionId, settings);
221
+ let created;
222
+ try {
223
+ created = await api.createSession(body);
224
+ } catch (error) {
225
+ throw createFailure(error, sessionId, settings.timeoutMinutes);
226
+ }
227
+ const answer = SteelSessionSchema.safeParse(created);
228
+ const url = answer.success ? URL.parse(answer.data.websocketUrl) : null;
229
+ if (!answer.success || url === null) {
230
+ const missing = hasId(created) ? "a usable websocketUrl" : "an id";
231
+ throw new PluginError(`Steel answered a session without ${missing}.`, {
232
+ kind: "refused",
233
+ handle: { sessionId: answer.success ? answer.data.id : sessionId }
234
+ });
235
+ }
236
+ const { id, debugUrl, sessionViewerUrl, timeout } = answer.data;
237
+ noteGrantedLifetime(timeout, body.timeout);
238
+ if (options.apiKey !== void 0) url.searchParams.set("apiKey", options.apiKey);
239
+ const link = viewerLink(sessionViewerUrl);
240
+ return {
241
+ handle: { sessionId: id },
242
+ protocol: "cdp",
243
+ url: url.href,
244
+ mask: debugUrl === void 0 ? [url.href] : [url.href, debugUrl],
245
+ ...link === void 0 ? {} : { link }
246
+ };
247
+ },
248
+ async release({ options, handle }, ctx) {
249
+ const parsed = HandleSchema.safeParse(handle);
250
+ if (!parsed.success) throw new PluginError("A Steel handle names its session by sessionId.", { kind: "refused" });
251
+ await createSteelApi(options).release(parsed.data.sessionId, ctx.signal);
252
+ }
253
+ });
254
+ export { plugin_default as t };
255
+
256
+ //# sourceMappingURL=plugin-C8AoyB8z.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-C8AoyB8z.js","names":[],"sources":["../src/steel-api.ts","../src/plugin.ts"],"sourcesContent":["import { createVendorApi, PluginError, z } from '@vostride/agent-qa-plugin-kit'\n\nexport const SteelSessionSchema = z.object({\n id: z.string().min(1),\n websocketUrl: z.string().min(1),\n debugUrl: z.string().min(1).optional(),\n sessionViewerUrl: z.string().min(1).optional(),\n timeout: z.number().optional(),\n})\n\nconst SessionStatusSchema = z.object({ status: z.string() })\n\n// Keeps the status of a refused create, so the plugin can name the setting that may cause it.\nexport class SteelCreateFailure extends PluginError {\n constructor(\n failure: PluginError,\n readonly status: number,\n ) {\n super(failure.message, { kind: failure.kind })\n }\n}\n\ninterface SteelApiInput {\n apiUrl: string\n apiKey?: string | undefined\n}\n\nexport interface SteelApi {\n createSession(body: Record<string, unknown>): Promise<unknown>\n release(id: string, signal?: AbortSignal): Promise<void>\n session(id: string, signal?: AbortSignal): Promise<unknown>\n listSessions(signal?: AbortSignal): Promise<void>\n}\n\nexport function createSteelApi(input: SteelApiInput): SteelApi {\n const api = createVendorApi({\n vendor: 'Steel',\n baseUrl: input.apiUrl,\n headers: input.apiKey === undefined ? {} : { 'steel-api-key': input.apiKey },\n })\n\n const sessionPath = (id: string) => `v1/sessions/${encodeURIComponent(id)}`\n\n async function session(id: string, signal?: AbortSignal): Promise<unknown> {\n const response = await api.send('GET', sessionPath(id), {\n headers: { accept: 'application/json' },\n signal,\n })\n if (response.status === 404) {\n await response.body?.cancel()\n return undefined\n }\n if (!response.ok) throw await api.failure(response, 'GET /v1/sessions/{id}')\n return api.json(response)\n }\n\n async function released(id: string, signal?: AbortSignal): Promise<boolean> {\n let current: unknown\n try {\n current = await session(id, signal)\n } catch {\n return false\n }\n if (current === undefined) return true\n const parsed = SessionStatusSchema.safeParse(current)\n return parsed.success && parsed.data.status !== 'live'\n }\n\n return {\n async createSession(body) {\n const response = await api.send('POST', 'v1/sessions', {\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify(body),\n })\n if (!response.ok) {\n const failure = await api.failure(response, 'POST /v1/sessions')\n throw new SteelCreateFailure(failure, response.status)\n }\n return api.json(response)\n },\n\n async release(id, signal) {\n const response = await api.send('POST', `${sessionPath(id)}/release`, {\n headers: { accept: 'application/json' },\n signal,\n })\n if (response.ok || response.status === 404) {\n await response.body?.cancel()\n return\n }\n const failure = await api.failure(response, 'POST /v1/sessions/{id}/release')\n if (await released(id, signal)) return\n throw failure\n },\n\n session,\n\n async listSessions(signal) {\n const response = await api.send('GET', 'v1/sessions?limit=1', {\n headers: { accept: 'application/json' },\n signal,\n })\n if (!response.ok) throw await api.failure(response, 'GET /v1/sessions')\n await response.body?.cancel()\n },\n }\n}\n","import { randomUUID } from 'node:crypto'\nimport {\n checkEndpoints,\n credential,\n definePlugin,\n PluginError,\n z,\n type AcquireInput,\n} from '@vostride/agent-qa-plugin-kit'\nimport { createSteelApi, SteelCreateFailure, SteelSessionSchema } from './steel-api.js'\n\nconst STEEL_API_URL = 'https://api.steel.dev'\nconst MINUTE_MS = 60_000\nconst LIFETIME_REFUSALS: readonly number[] = [400, 422]\nconst SMALLEST_PLAN_CAP_MINUTES = 15\nconst LIFETIME_WORDS = /timeout|lifetime|duration|minute|plan|limit/i\n\nconst options = z.strictObject({\n apiKey: credential('The Steel API key; leave it out for a self-hosted steel-browser').optional(),\n apiUrl: z\n .url()\n .default(STEEL_API_URL)\n .describe(\"Steel's API, https:// unless it points at this machine\"),\n})\n\nconst settings = z.strictObject({\n timeoutMinutes: z\n .int()\n .min(1)\n .max(1440)\n .default(1440)\n .describe('Minutes before Steel ends the session; your Steel plan may cap it lower'),\n idleMinutes: z\n .int()\n .min(1)\n .max(60)\n .default(5)\n .describe('Minutes with no CDP command before Steel ends the session'),\n solveCaptcha: z.boolean().default(false).describe(\"Steel's CAPTCHA solving\"),\n blockAds: z.boolean().default(false).describe(\"Steel's ad blocking\"),\n fingerprint: z\n .boolean()\n .default(false)\n .describe(\"Steel's browser fingerprint injection, which changes what pages see\"),\n proxy: z\n .union([z.boolean(), z.record(z.string(), z.unknown())])\n .default(false)\n .describe(\"Steel's proxy, true or Steel's proxy object, sent as useProxy\"),\n proxyUrl: credential(\n \"Your own proxy, http(s)://user:password@host:port, sent instead of Steel's\",\n ).optional(),\n dimensions: z\n .strictObject({ width: z.int().positive(), height: z.int().positive() })\n .optional()\n .describe('The browser window size'),\n region: z.string().min(1).optional().describe('The Steel region, for example us-east'),\n profile: z\n .string()\n .min(1)\n .optional()\n .describe('A Steel profile id loaded read-only; needs context: reuse'),\n extensions: z\n .array(z.string().min(1))\n .min(1)\n .optional()\n .describe('Steel extension ids loaded into the browser'),\n extra: z\n .record(z.string(), z.unknown())\n .optional()\n .describe('Fields merged last into the session create request, overriding the rest'),\n})\n\ntype SteelSettings = z.output<typeof settings>\ntype Session = AcquireInput<unknown, unknown>['session']\n\nfunction checkSettings(input: SteelSettings, session: Session): void {\n if (input.profile !== undefined && (session.platform !== 'web' || session.context !== 'reuse')) {\n throw new PluginError(\n 'profile loads only into a reused context; set context: reuse on the remote.',\n { kind: 'refused', path: 'settings.profile' },\n )\n }\n if (input.extra !== undefined && Object.hasOwn(input.extra, 'sessionId')) {\n throw new PluginError(\n 'extra cannot set sessionId; the plugin chooses it to release the session.',\n { kind: 'refused', path: 'settings.extra.sessionId' },\n )\n }\n if (input.proxyUrl !== undefined && input.proxy !== false) {\n throw new PluginError('proxy and proxyUrl both choose a proxy; set one of them.', {\n kind: 'refused',\n path: 'settings.proxyUrl',\n })\n }\n}\n\nfunction createBody(sessionId: string, input: SteelSettings): Record<string, unknown> {\n return {\n sessionId,\n timeout: input.timeoutMinutes * MINUTE_MS,\n inactivityTimeout: input.idleMinutes * MINUTE_MS,\n solveCaptcha: input.solveCaptcha,\n blockAds: input.blockAds,\n ...(input.proxyUrl === undefined ? { useProxy: input.proxy } : {}),\n stealthConfig: { skipFingerprintInjection: !input.fingerprint },\n ...(input.proxyUrl === undefined ? {} : { proxyUrl: input.proxyUrl }),\n ...(input.dimensions === undefined ? {} : { dimensions: input.dimensions }),\n ...(input.region === undefined ? {} : { region: input.region }),\n ...(input.profile === undefined ? {} : { profileId: input.profile }),\n ...(input.extensions === undefined ? {} : { extensionIds: input.extensions }),\n ...input.extra,\n }\n}\n\n// A create Steel refused with a 4xx made no session. Anything else (no answer, a 5xx, an\n// unreadable answer) may have, so it carries the handle that releases it.\nfunction mayHaveCreated(error: unknown): boolean {\n return !(error instanceof SteelCreateFailure) || error.status >= 500\n}\n\n// How Steel refuses a lifetime above the plan's cap is undocumented, so the hint is added only when\n// the lifetime asked for is above the smallest cap and Steel's reason, if any, may be about it.\nfunction lifetimeMayBeRefused(error: unknown, timeoutMinutes: number): boolean {\n if (!(error instanceof SteelCreateFailure) || !LIFETIME_REFUSALS.includes(error.status)) {\n return false\n }\n if (timeoutMinutes <= SMALLEST_PLAN_CAP_MINUTES) return false\n const reason = error.message.split(': ').slice(1).join(': ')\n return reason === '' || LIFETIME_WORDS.test(reason)\n}\n\nfunction createFailure(error: unknown, sessionId: string, timeoutMinutes: number): PluginError {\n const message = error instanceof Error ? error.message : String(error)\n const kind = error instanceof PluginError ? error.kind : 'refused'\n const text = lifetimeMayBeRefused(error, timeoutMinutes)\n ? `${message.replace(/[\\s.;,]+$/, '')}; if your Steel plan caps sessions below ${String(timeoutMinutes)} minutes, set settings.timeoutMinutes to the cap`\n : message\n return new PluginError(text, {\n kind,\n ...(mayHaveCreated(error) ? { handle: { sessionId } } : {}),\n })\n}\n\nfunction minutesOf(ms: number): string {\n return String(Number((ms / MINUTE_MS).toFixed(1)))\n}\n\nfunction noteGrantedLifetime(granted: number | undefined, asked: unknown): void {\n if (granted === undefined || typeof asked !== 'number' || granted >= asked) return\n process.stderr.write(\n `Steel granted a ${minutesOf(granted)}-minute session; ${minutesOf(asked)} minutes were asked for\\n`,\n )\n}\n\nfunction viewerLink(url: string | undefined): string | undefined {\n if (url === undefined) return undefined\n return URL.parse(url)?.protocol === 'https:' ? url : undefined\n}\n\nconst HandleSchema = z.object({ sessionId: z.string().min(1) })\n\nfunction hasId(answer: unknown): boolean {\n if (typeof answer !== 'object' || answer === null || !('id' in answer)) return false\n return typeof answer.id === 'string' && answer.id !== ''\n}\n\nfunction refusedKey(error: unknown): boolean {\n return error instanceof PluginError && error.kind === 'auth'\n}\n\nfunction failureLine(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\nexport default definePlugin({\n options,\n settings,\n serves: ['cdp'],\n skill: './skill',\n\n initialize({ options }) {\n checkEndpoints(options, [{ path: 'apiUrl', scheme: 'https' }])\n },\n\n async check({ options }, ctx) {\n try {\n await createSteelApi(options).listSessions(ctx.signal)\n } catch (error) {\n if (!refusedKey(error)) return { state: 'warn', lines: [failureLine(error)] }\n const line =\n options.apiKey === undefined\n ? 'Steel asked for an API key; set options.apiKey'\n : 'the API key was refused'\n return { state: 'warn', lines: [line] }\n }\n const line =\n options.apiKey === undefined\n ? 'Steel answered without an API key'\n : 'the API key was accepted'\n return { state: 'ok', lines: [line] }\n },\n\n async acquire({ options, settings, session }) {\n if (session.platform !== 'web' || session.engine !== 'chromium') {\n const what = session.platform === 'web' ? session.engine : session.platform\n throw new PluginError(`Steel runs Chromium web sessions only, not ${what}.`, {\n kind: 'refused',\n })\n }\n checkSettings(settings, session)\n const api = createSteelApi(options)\n const sessionId = randomUUID()\n const body = createBody(sessionId, settings)\n let created: unknown\n // The create is not cancelled: once Steel has a session, the handle must come back to be\n // released, and the kit releases a handle that arrives after a cancel was answered.\n try {\n created = await api.createSession(body)\n } catch (error) {\n throw createFailure(error, sessionId, settings.timeoutMinutes)\n }\n const answer = SteelSessionSchema.safeParse(created)\n const url = answer.success ? URL.parse(answer.data.websocketUrl) : null\n if (!answer.success || url === null) {\n const missing = hasId(created) ? 'a usable websocketUrl' : 'an id'\n throw new PluginError(`Steel answered a session without ${missing}.`, {\n kind: 'refused',\n handle: { sessionId: answer.success ? answer.data.id : sessionId },\n })\n }\n const { id, debugUrl, sessionViewerUrl, timeout } = answer.data\n noteGrantedLifetime(timeout, body.timeout)\n if (options.apiKey !== undefined) url.searchParams.set('apiKey', options.apiKey)\n const link = viewerLink(sessionViewerUrl)\n return {\n handle: { sessionId: id },\n protocol: 'cdp' as const,\n url: url.href,\n mask: debugUrl === undefined ? [url.href] : [url.href, debugUrl],\n ...(link === undefined ? {} : { link }),\n }\n },\n\n async release({ options, handle }, ctx) {\n const parsed = HandleSchema.safeParse(handle)\n if (!parsed.success) {\n throw new PluginError('A Steel handle names its session by sessionId.', { kind: 'refused' })\n }\n await createSteelApi(options).release(parsed.data.sessionId, ctx.signal)\n },\n})\n"],"mappings":"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"}
package/dist/plugin.js ADDED
@@ -0,0 +1 @@
1
+ export { t as default } from "./plugin-C8AoyB8z.js";
@@ -0,0 +1,105 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "timeoutMinutes": {
6
+ "default": 1440,
7
+ "description": "Minutes before Steel ends the session; your Steel plan may cap it lower",
8
+ "type": "integer",
9
+ "minimum": 1,
10
+ "maximum": 1440
11
+ },
12
+ "idleMinutes": {
13
+ "default": 5,
14
+ "description": "Minutes with no CDP command before Steel ends the session",
15
+ "type": "integer",
16
+ "minimum": 1,
17
+ "maximum": 60
18
+ },
19
+ "solveCaptcha": {
20
+ "default": false,
21
+ "description": "Steel's CAPTCHA solving",
22
+ "type": "boolean"
23
+ },
24
+ "blockAds": {
25
+ "default": false,
26
+ "description": "Steel's ad blocking",
27
+ "type": "boolean"
28
+ },
29
+ "fingerprint": {
30
+ "default": false,
31
+ "description": "Steel's browser fingerprint injection, which changes what pages see",
32
+ "type": "boolean"
33
+ },
34
+ "proxy": {
35
+ "default": false,
36
+ "description": "Steel's proxy, true or Steel's proxy object, sent as useProxy",
37
+ "anyOf": [
38
+ {
39
+ "type": "boolean"
40
+ },
41
+ {
42
+ "type": "object",
43
+ "propertyNames": {
44
+ "type": "string"
45
+ },
46
+ "additionalProperties": {}
47
+ }
48
+ ]
49
+ },
50
+ "proxyUrl": {
51
+ "type": "string",
52
+ "credential": true,
53
+ "description": "Your own proxy, http(s)://user:password@host:port, sent instead of Steel's"
54
+ },
55
+ "dimensions": {
56
+ "description": "The browser window size",
57
+ "type": "object",
58
+ "properties": {
59
+ "width": {
60
+ "type": "integer",
61
+ "exclusiveMinimum": 0,
62
+ "maximum": 9007199254740991
63
+ },
64
+ "height": {
65
+ "type": "integer",
66
+ "exclusiveMinimum": 0,
67
+ "maximum": 9007199254740991
68
+ }
69
+ },
70
+ "required": [
71
+ "width",
72
+ "height"
73
+ ],
74
+ "additionalProperties": false
75
+ },
76
+ "region": {
77
+ "description": "The Steel region, for example us-east",
78
+ "type": "string",
79
+ "minLength": 1
80
+ },
81
+ "profile": {
82
+ "description": "A Steel profile id loaded read-only; needs context: reuse",
83
+ "type": "string",
84
+ "minLength": 1
85
+ },
86
+ "extensions": {
87
+ "description": "Steel extension ids loaded into the browser",
88
+ "minItems": 1,
89
+ "type": "array",
90
+ "items": {
91
+ "type": "string",
92
+ "minLength": 1
93
+ }
94
+ },
95
+ "extra": {
96
+ "description": "Fields merged last into the session create request, overriding the rest",
97
+ "type": "object",
98
+ "propertyNames": {
99
+ "type": "string"
100
+ },
101
+ "additionalProperties": {}
102
+ }
103
+ },
104
+ "additionalProperties": false
105
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@vostride/agent-qa-plugin-steel",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "private": false,
5
+ "description": "agent-qa plugin for Steel: creates a Steel browser session before a test connects and releases it after.",
6
+ "keywords": [
7
+ "agent-qa",
8
+ "agent-qa-plugin",
9
+ "steel"
10
+ ],
11
+ "license": "FSL-1.1-ALv2",
12
+ "homepage": "https://github.com/vostride/agent-qa#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/vostride/agent-qa/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/vostride/agent-qa.git",
19
+ "directory": "plugins/steel"
20
+ },
21
+ "engines": {
22
+ "node": ">=24"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "type": "module",
29
+ "exports": {
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "skill",
35
+ "LICENSE.md",
36
+ "NOTICE.md"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsdown && node scripts/emit.mjs",
40
+ "test": "vitest run",
41
+ "typecheck": "tsc --noEmit",
42
+ "clean": "rm -rf dist .turbo"
43
+ },
44
+ "dependencies": {
45
+ "@vostride/agent-qa-plugin-kit": "0.3.2"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "~24.19.2",
49
+ "@vostride/agent-qa-arch": "0.3.2",
50
+ "tsdown": "~0.23.4",
51
+ "typescript": "~7.0.2",
52
+ "vitest": "~5.0.5"
53
+ },
54
+ "agent-qa": {
55
+ "plugin": {
56
+ "kind": "remote",
57
+ "runs": "process",
58
+ "protocol": 1,
59
+ "entry": "./dist/main.js",
60
+ "serves": [
61
+ "cdp"
62
+ ],
63
+ "methods": [
64
+ "check"
65
+ ],
66
+ "options": "./dist/options.schema.json",
67
+ "settings": "./dist/settings.schema.json",
68
+ "skill": "./skill"
69
+ }
70
+ }
71
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: agent-qa-plugin-steel
3
+ description: Use when configuring agent-qa web tests to run in Steel cloud browsers through the Steel plugin, or when a Steel remote is refused.
4
+ ---
5
+
6
+ # Steel remotes for agent-qa
7
+
8
+ The Steel plugin gives an agent-qa web test a Steel browser session. Before the test connects it
9
+ creates a session through Steel's API, and after the test it releases the session. Steel runs
10
+ Chromium only. CAPTCHA solving, ad blocking, Steel's proxies and Steel's fingerprint injection are
11
+ off unless a setting turns them on, so a test sees the app the way its users do.
12
+
13
+ ## Declare the plugin and a remote
14
+
15
+ ```yaml
16
+ plugins:
17
+ - name: steel
18
+ package: "@vostride/agent-qa-plugin-steel"
19
+ options:
20
+ apiKey: "{{secret:STEEL_API_KEY}}"
21
+
22
+ registry:
23
+ remotes:
24
+ - name: steel
25
+ plugin: steel
26
+ settings:
27
+ idleMinutes: 5
28
+ ```
29
+
30
+ - The `plugins` entry holds the account: `apiKey`, and `apiUrl`, which defaults to
31
+ `https://api.steel.dev`. Write the key as a `{{secret:<name>}}` reference to a name in the file
32
+ `workspace.secretsFile` names. Never write the key itself in the config.
33
+ - The remote holds one place: `plugin` names the `plugins` entry, and `settings` hold that place's
34
+ Steel settings.
35
+ - `use.browser.remote: steel` in the config, a suite or a test runs the test there.
36
+ - Install the package at the exact version of agent-qa the project runs:
37
+ `npm install --save-dev --save-exact @vostride/agent-qa-plugin-steel@<agent-qa version>`.
38
+ `agent-qa plugins list` prints that line when the package is missing or the version differs.
39
+ - For a self-hosted steel-browser, set `apiUrl` to its loopback `http://` address, leave out
40
+ `apiKey`, and set `use.workers: 1`: it serves one session at a time, and releasing one ends
41
+ whatever session is active. It returns no session link.
42
+
43
+ ## Settings
44
+
45
+ - `timeoutMinutes` (default `1440`, 1 to 1440): minutes before Steel ends the session. Steel's
46
+ plans cap it: Launch at 15, Scale at 60, Enterprise at up to 1440.
47
+ - `idleMinutes` (default `5`, 1 to 60): minutes with no CDP command before Steel ends the session.
48
+ agent-qa's heartbeat keeps a running test from going idle.
49
+ - `solveCaptcha` (default `false`), `blockAds` (default `false`): Steel's CAPTCHA solving and ad
50
+ blocking.
51
+ - `fingerprint` (default `false`): Steel's fingerprint injection. The plugin always tells Steel to
52
+ skip it unless this is `true`.
53
+ - `proxy` (default `false`): `true`, or Steel's proxy object, sent as `useProxy`.
54
+ - `proxyUrl`: your own proxy, `http(s)://user:password@host:port`, a credential. Write it as a
55
+ `{{secret:<name>}}` reference. It is sent instead of Steel's proxy, so do not also set `proxy`.
56
+ - `dimensions` (`{ width, height }`), `region` (for example `us-east`), `extensions` (extension
57
+ ids): Steel's own create fields.
58
+ - `profile`: a Steel profile id. It loads read-only: nothing the test does is saved back. It needs
59
+ `context: reuse` on the remote, because the profile lives in the browser's first context:
60
+
61
+ ```yaml
62
+ - name: steel-signed-in
63
+ plugin: steel
64
+ context: reuse
65
+ settings:
66
+ profile: <Steel profile id>
67
+ ```
68
+
69
+ - `extra`: any other Steel request field. It is merged last, so it overrides the settings above.
70
+ It cannot set `sessionId`, which the plugin chooses to release the session.
71
+
72
+ ## When a remote is refused
73
+
74
+ - `auth`: Steel refused the API key. Check the secret's value.
75
+ - `capacity`: Steel answered 429. The account has no free sessions, or is rate limited. Wait, or
76
+ lower the run's concurrency.
77
+ - `refused` whose line ends `set settings.timeoutMinutes to the cap`: Steel refused the create with
78
+ `400` or `422`, and its reason may be the plan capping sessions below the lifetime asked for. Set
79
+ `timeoutMinutes` to the plan's cap, for example `15` on Launch. A `400` or `422` whose reason
80
+ names another field ends without this, so fix the field it names.
81
+ - `refused` with path `settings.profile`: the remote sets `profile` without `context: reuse`. Add
82
+ `context: reuse` to the remote.
83
+ - `refused` with path `settings.extra.sessionId`: `extra` sets `sessionId`. Remove it.
84
+ - `refused` with path `settings.proxyUrl`: the remote sets both `proxy` and `proxyUrl`. Keep one.
85
+ - `refused` naming a browser or platform: Steel runs Chromium web tests only. Use
86
+ `use.browser.name: chromium`, or another remote.
87
+ - `refused` with `Unrecognized key: "recording"`: Steel has no switch to turn recording off, so
88
+ the plugin has no `recording` setting. Remove it.
89
+ - `refused` at `options.apiUrl`: the endpoint must use `https://` with no user or password, unless
90
+ it points at this machine.
91
+ - `unreachable`: Steel could not be reached. Check the network, or that a self-hosted
92
+ steel-browser is running.
93
+
94
+ ## Recording
95
+
96
+ Steel records every session and keeps the replay on its dashboard, behind its login. agent-qa's
97
+ masking does not reach that replay, so a secret a test types may show in it. Steel offers no switch
98
+ to turn recording off. The session link opens the session's page in Steel's dashboard. Steel's
99
+ debug URL lets anyone who has it drive the session, so agent-qa masks it and never shows it.
100
+
101
+ ## Check and describe
102
+
103
+ - `agent-qa doctor` runs the plugin's account check. It lists sessions to prove the key and opens
104
+ none.
105
+ - `agent-qa plugins describe steel --json` lists every option and setting with its type, default
106
+ and description.