@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
884 lines (792 loc) • 30 kB
text/typescript
/**
* KXM Browser Automation & Steel Session Client
*
* Playwright testing and verification use Obscura by default
* (`resolveBrowserCdpEndpoint()`). Steel remains the client for human
* takeover, MFA, and the live session viewer (`KXM_BROWSER=steel`).
* Manages remote Steel sessions, CDP endpoints, human takeover handoffs,
* and automated cleanup without leaking secrets.
*
* steel.kontextmind.com is reached only through Caddy and Authentik forward
* auth. Clients send `Authorization: Basic` for the svc-steel credential.
* A Bearer token is refused. `STEEL_API_KEY` is deprecated and is not
* enforced by Steel or Caddy. Do not put a credential in the URL.
*/
import { execSync } from "node:child_process";
export type SessionState =
| "AGENT_CONTROL"
| "AUTH_REQUIRED"
| "HUMAN_CONTROL"
| "VERIFY_AUTHENTICATION"
| "RELEASED"
| "EXPIRED"
| "FAILED";
export interface SteelSession {
id: string;
createdAt: string;
status: "idle" | "live" | "released" | "failed";
state: SessionState;
websocketUrl: string;
debugUrl: string;
debuggerUrl: string;
sessionViewerUrl: string;
timeoutMs: number;
lastActiveAt: number;
activeController: "agent" | "human" | "none";
takeoverReason?: string | undefined;
userAgent?: string | undefined;
}
export interface ViewportPreset {
name: string;
category: "mobile" | "tablet" | "desktop" | "laptop";
width: number;
height: number;
deviceScaleFactor?: number | undefined;
isMobile?: boolean | undefined;
hasTouch?: boolean | undefined;
}
export const VIEWPORT_PRESETS: Record<string, ViewportPreset> = Object.freeze({
"mobile-sm": { name: "Mobile Small (SE)", category: "mobile", width: 375, height: 667, isMobile: true, hasTouch: true },
"mobile": { name: "Mobile (iPhone 16 / 15 Pro)", category: "mobile", width: 393, height: 852, isMobile: true, hasTouch: true },
"mobile-lg": { name: "Mobile Large (Pro Max)", category: "mobile", width: 430, height: 932, isMobile: true, hasTouch: true },
"pixel": { name: "Google Pixel 8/9", category: "mobile", width: 412, height: 924, isMobile: true, hasTouch: true },
"galaxy": { name: "Samsung Galaxy S24", category: "mobile", width: 360, height: 780, isMobile: true, hasTouch: true },
"tablet": { name: "Tablet (iPad Air / Mini)", category: "tablet", width: 820, height: 1180, isMobile: true, hasTouch: true },
"tablet-lg": { name: "Tablet Large (iPad Pro 12.9)", category: "tablet", width: 1024, height: 1366, isMobile: true, hasTouch: true },
"laptop": { name: "Standard Laptop", category: "laptop", width: 1366, height: 768 },
"macbook-13": { name: "MacBook Air 13", category: "laptop", width: 1440, height: 900 },
"macbook-16": { name: "MacBook Pro 16", category: "laptop", width: 1728, height: 1117 },
"desktop": { name: "Desktop FHD (1080p)", category: "desktop", width: 1920, height: 1080 },
"desktop-2k": { name: "Desktop QHD (1440p)", category: "desktop", width: 2560, height: 1440 },
"desktop-4k": { name: "Desktop 4K UHD", category: "desktop", width: 3840, height: 2160 },
});
export function resolveViewportDimensions(
presetOrDims?: string | { width: number; height: number } | undefined
): { width: number; height: number } {
if (!presetOrDims) {
return { width: 1920, height: 1080 };
}
if (typeof presetOrDims === "string") {
const matched = VIEWPORT_PRESETS[presetOrDims.toLowerCase()];
if (matched) {
return { width: matched.width, height: matched.height };
}
return { width: 1920, height: 1080 };
}
return presetOrDims;
}
export interface CreateSessionOptions {
timeoutMs?: number | undefined;
dimensions?: { width: number; height: number } | undefined;
viewportPreset?: string | undefined;
userAgent?: string | undefined;
proxy?: string | undefined;
}
export interface ScrapeResult {
content: {
html: string;
};
metadata: {
statusCode: number;
title: string;
description?: string | undefined;
language?: string | undefined;
urlSource: string;
timestamp: string;
};
links: Array<{ url: string; text: string }>;
}
export interface ScreenshotResult {
path?: string | undefined;
base64?: string | undefined;
url: string;
}
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface AnnotationItem {
id?: string | undefined;
label: string;
note: string;
selector?: string | undefined;
boundingBox?: BoundingBox | undefined;
severity?: "suggestion" | "fix" | "blocker" | undefined;
}
export interface AnnotationFeedback {
sessionId?: string | undefined;
url: string;
sectionSelector?: string | undefined;
screenshotBase64?: string | undefined;
screenshotPath?: string | undefined;
annotations: AnnotationItem[];
overallSummary: string;
requestedChanges: string[];
capturedAt: string;
}
export interface SteelConfig {
apiUrl: string;
/** Legacy Steel key. Omitted once Authentik Basic auth is configured. */
apiKey?: string | undefined;
/**
* Full `Authorization` value, for example `Basic <base64>`.
* Present when Authentik app-password auth is configured.
*/
authorization?: string | undefined;
uiUrl?: string | undefined;
timeoutMs?: number | undefined;
}
/** Inputs for {@link resolveSteelConfig}. Environment variables fill anything omitted. */
export interface SteelConfigOverrides extends Partial<SteelConfig> {
/** Full Authorization value, or a bare base64 credential. Wins over the other auth inputs. */
authHeader?: string | undefined;
/** `base64(user:token)`, with or without a leading `Basic `. */
authBasic?: string | undefined;
/** Authentik username, for example `svc-steel`. Used with `authToken`. */
authUser?: string | undefined;
/** Authentik app password. Used with `authUser`. */
authToken?: string | undefined;
}
export class SteelAuthConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "SteelAuthConfigError";
}
}
/** Authentik challenged the request with a redirect. The message never includes credentials. */
export class SteelAuthRedirectError extends Error {
readonly status: number;
readonly host: string;
constructor(status: number, host: string) {
super(
`Steel request was redirected (${status}) to ${host}. ` +
"Send Authorization: Basic via STEEL_AUTH_BASIC or STEEL_AUTH_USER and STEEL_AUTH_TOKEN. " +
"A Bearer token is not accepted.",
);
this.name = "SteelAuthRedirectError";
this.status = status;
this.host = host;
}
}
const LEGACY_STEEL_AUTH_WARNING =
"kxm: STEEL_API_KEY is deprecated for Steel. Steel and Caddy do not enforce it. " +
"Set STEEL_AUTH_HEADER, or STEEL_AUTH_BASIC, or STEEL_AUTH_USER and STEEL_AUTH_TOKEN. " +
"Those override STEEL_API_KEY. Send Authorization on the request, not in the URL.\n";
let legacySteelAuthWarned = false;
/** Test hook. Production calls warn at most once per process. */
export function resetLegacySteelAuthWarningForTests(): void {
legacySteelAuthWarned = false;
}
function warnLegacySteelAuth(): void {
if (legacySteelAuthWarned) return;
legacySteelAuthWarned = true;
process.stderr.write(LEGACY_STEEL_AUTH_WARNING);
}
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) return trimmed;
}
return undefined;
}
function normalizeAuthorization(raw: string): string {
if (/[\r\n]/.test(raw)) {
throw new SteelAuthConfigError("Steel authorization value contains a line break.");
}
const value = raw.trim();
if (!value) {
throw new SteelAuthConfigError("Steel authorization value is empty.");
}
const basicPrefix = /^basic\s+(.+)$/i.exec(value);
if (basicPrefix) {
const credential = basicPrefix[1] ?? "";
if (!credential || /\s/.test(credential)) {
throw new SteelAuthConfigError("Steel Basic credential must be a single base64 token.");
}
return `Basic ${credential}`;
}
if (/\s/.test(value)) {
return value;
}
return `Basic ${value}`;
}
/**
* Resolve the Authentik `Authorization` value.
* Precedence: auth header override, then `STEEL_AUTH_BASIC`, then user + token.
* Returns undefined when none of those are set so the legacy API key can apply.
*/
export function resolveSteelAuthorization(overrides?: SteelConfigOverrides): string | undefined {
const header = firstNonEmpty(overrides?.authHeader, overrides?.authorization, process.env.STEEL_AUTH_HEADER);
if (header) return normalizeAuthorization(header);
const basic = firstNonEmpty(overrides?.authBasic, process.env.STEEL_AUTH_BASIC);
if (basic) return normalizeAuthorization(basic);
const user = firstNonEmpty(overrides?.authUser, process.env.STEEL_AUTH_USER);
const token = firstNonEmpty(overrides?.authToken, process.env.STEEL_AUTH_TOKEN);
if (user || token) {
if (!user || !token) {
throw new SteelAuthConfigError(
"Steel Basic auth needs both STEEL_AUTH_USER and STEEL_AUTH_TOKEN, or STEEL_AUTH_BASIC.",
);
}
return `Basic ${Buffer.from(`${user}:${token}`, "utf8").toString("base64")}`;
}
return undefined;
}
/**
* Headers for Steel HTTP and for Playwright `chromium.connectOverCDP(url, { headers })`.
* Basic auth wins and does not attach the legacy API key.
*/
export function steelRequestHeaders(config: Pick<SteelConfig, "authorization" | "apiKey">): Record<string, string> {
if (config.authorization) {
return { Authorization: config.authorization };
}
if (config.apiKey) {
return { "x-steel-api-key": config.apiKey };
}
return {};
}
export function steelAuthRedirectError(res: {
status: number;
headers: { get(name: string): string | null };
}): SteelAuthRedirectError {
let host = "the identity provider";
const location = res.headers.get("location");
if (location) {
try {
host = new URL(location, "https://id.kxmd.dev").host;
} catch {
host = "the identity provider";
}
}
return new SteelAuthRedirectError(res.status, host);
}
export function resolvePassCliApiKey(
execFn: (cmd: string) => string = (cmd) =>
execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], timeout: 5000 }),
): string | undefined {
if (typeof process === "undefined" || process.env.USE_PASS_CLI === "false") {
return undefined;
}
if (process.env.PASS_CLI_OUTPUT_MOCK) {
try {
const parsed = JSON.parse(process.env.PASS_CLI_OUTPUT_MOCK);
const extraFields = parsed?.item?.content?.extra_fields || [];
const customSections = parsed?.item?.content?.content?.Custom?.sections || [];
const sectionFields = customSections.flatMap((s: any) => s.section_fields || []);
const allFields = [...extraFields, ...sectionFields];
return allFields.find((f: any) => f.name === "STEEL_API_KEY")?.content?.Hidden;
} catch {
return undefined;
}
}
try {
// Deprecated lookup. The hosted server does not enforce STEEL_API_KEY.
const output = execFn(
'pass-cli item view --vault-name "AI Provider Keys" --item-title "Steel Browser (KontextMind DOKS)" --output json',
);
const parsed = JSON.parse(output);
const extraFields = parsed?.item?.content?.extra_fields || [];
const customSections = parsed?.item?.content?.content?.Custom?.sections || [];
const sectionFields = customSections.flatMap((s: any) => s.section_fields || []);
const allFields = [...extraFields, ...sectionFields];
return allFields.find((f: any) => f.name === "STEEL_API_KEY")?.content?.Hidden;
} catch {
return undefined;
}
}
/**
* Resolve Steel configuration from the environment.
* Does not write secrets to disk or logs.
* Authentik Basic auth overrides `STEEL_API_KEY`. Steel and Caddy do not enforce that key.
*
* Authentik Basic auth (`STEEL_AUTH_HEADER`, `STEEL_AUTH_BASIC`, or
* `STEEL_AUTH_USER` + `STEEL_AUTH_TOKEN`) wins over `STEEL_API_KEY`.
* The legacy key is kept only when no Basic credential is configured, and
* a one-time deprecation warning is written to stderr.
*/
export function resolveSteelConfig(overrides?: SteelConfigOverrides): SteelConfig {
const apiUrl =
overrides?.apiUrl ||
process.env.STEEL_API_URL ||
"https://steel.kontextmind.com";
const authorization = resolveSteelAuthorization(overrides);
let apiKey: string | undefined;
if (!authorization) {
apiKey = overrides?.apiKey || process.env.STEEL_API_KEY || resolvePassCliApiKey();
if (apiKey) warnLegacySteelAuth();
}
const uiUrl =
overrides?.uiUrl ||
(overrides?.apiUrl ? `${overrides.apiUrl.replace(/\/$/, "")}/ui` : undefined) ||
process.env.STEEL_UI_URL ||
`${apiUrl.replace(/\/$/, "")}/ui`;
return {
apiUrl: apiUrl.replace(/\/$/, ""),
apiKey,
authorization,
uiUrl,
timeoutMs: overrides?.timeoutMs || 300000, // 5 minutes default
};
}
/**
* Format a remote CDP connection URL for Playwright or agent-browser.
* The legacy `apiKey` query parameter is added only when Basic auth is unset.
*/
export function formatCDPEndpoint(session: Pick<SteelSession, "id" | "websocketUrl">, config: SteelConfig): string {
const baseApi = config.apiUrl;
const urlObj = new URL(baseApi);
const isSecure = urlObj.protocol === "https:";
const wsProtocol = isSecure ? "wss:" : "ws:";
const host = urlObj.host;
const searchParams = new URLSearchParams();
searchParams.set("sessionId", session.id);
if (!config.authorization && config.apiKey) {
searchParams.set("apiKey", config.apiKey);
}
return `${wsProtocol}//${host}/v1/devtools?${searchParams.toString()}`;
}
export interface SteelCdpConnect {
/** WebSocket URL. Credentials stay out of it when Authentik Basic auth is set. */
url: string;
/** Pass as the second argument to `chromium.connectOverCDP(url, { headers })`. */
headers: Record<string, string>;
}
/**
* CDP URL plus the headers Playwright must send on the WebSocket handshake.
* Call `chromium.connectOverCDP(url, { headers })`. Do not put the credential in the URL.
*/
export function formatCDPConnect(
session: Pick<SteelSession, "id" | "websocketUrl">,
config: SteelConfig,
): SteelCdpConnect {
return {
url: formatCDPEndpoint(session, config),
headers: steelRequestHeaders(config),
};
}
export const DEFAULT_OBSCURA_CDP_URL = "http://127.0.0.1:9222";
const DEFAULT_OBSCURA_PORT = 9222;
function obscuraListenPort(): number {
const raw = process.env.OBSCURA_PORT?.trim() ?? "";
if (raw === "") return DEFAULT_OBSCURA_PORT;
if (!/^[0-9]+$/.test(raw)) {
throw new Error(`OBSCURA_PORT must be an integer from 1 to 65535 (received ${JSON.stringify(process.env.OBSCURA_PORT)})`);
}
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`OBSCURA_PORT must be an integer from 1 to 65535 (received ${JSON.stringify(process.env.OBSCURA_PORT)})`);
}
return port;
}
/**
* CDP URL for a local Obscura browser.
* `OBSCURA_CDP_URL` wins. Otherwise `http://127.0.0.1:${OBSCURA_PORT:-9222}`.
*/
export function resolveObscuraCdpEndpoint(): string {
const explicit = process.env.OBSCURA_CDP_URL?.trim() ?? "";
if (explicit !== "") return explicit;
const port = obscuraListenPort();
if (port === DEFAULT_OBSCURA_PORT) return DEFAULT_OBSCURA_CDP_URL;
return `http://127.0.0.1:${port}`;
}
/**
* Playwright CDP target.
* Obscura by default, with empty headers. `KXM_BROWSER=steel` uses {@link formatCDPConnect}.
*/
export function resolveBrowserCdpConnect(
session?: Pick<SteelSession, "id" | "websocketUrl">,
config?: SteelConfig,
): SteelCdpConnect {
const browser = (process.env.KXM_BROWSER ?? "").trim().toLowerCase();
if (browser === "" || browser === "obscura") {
return { url: resolveObscuraCdpEndpoint(), headers: {} };
}
if (browser === "steel") {
if (!session?.id) {
throw new Error("KXM_BROWSER=steel requires a Steel session id");
}
return formatCDPConnect(session, config ?? resolveSteelConfig());
}
throw new Error(`Unsupported KXM_BROWSER value ${JSON.stringify(process.env.KXM_BROWSER)}; expected "obscura" or "steel"`);
}
/**
* Playwright CDP endpoint.
* Obscura by default. `KXM_BROWSER=steel` returns the URL from {@link formatCDPConnect}.
* Pass {@link resolveBrowserCdpConnect} headers into `chromium.connectOverCDP`, or call {@link connectBrowserOverCdp}.
*/
export function resolveBrowserCdpEndpoint(
session?: Pick<SteelSession, "id" | "websocketUrl">,
config?: SteelConfig,
): string {
return resolveBrowserCdpConnect(session, config).url;
}
/**
* Connect Playwright over CDP.
* Obscura is called with the URL only. `KXM_BROWSER=steel` passes Authentik or legacy Steel headers on the handshake.
*/
export async function connectBrowserOverCdp<T>(
connectOverCDP: (url: string, options?: { headers?: Record<string, string> }) => Promise<T>,
session?: Pick<SteelSession, "id" | "websocketUrl">,
config?: SteelConfig,
): Promise<T> {
const { url, headers } = resolveBrowserCdpConnect(session, config);
if (Object.keys(headers).length === 0) return connectOverCDP(url);
return connectOverCDP(url, { headers });
}
/**
* Redact sensitive API keys and tokens from URLs and objects for logging.
*/
export function sanitizeLogOutput<T>(input: T): T {
if (typeof input === "string") {
const redacted = input
.replace(/apiKey=[^&\s]+/gi, "apiKey=[REDACTED]")
.replace(/([?&]authorization=)[^&\s]+/gi, "$1[REDACTED]")
.replace(/authorization:\s*(?:basic\s+)?\S+/gi, "authorization: [REDACTED]")
.replace(/\bBasic\s+(?:[A-Za-z0-9+/]*[+/=0-9][A-Za-z0-9+/]*={0,2})/g, "Basic [REDACTED]")
.replace(/steel_[a-f0-9]+/g, "steel_[REDACTED]");
return redacted as unknown as T;
}
if (Array.isArray(input)) {
return input.map(sanitizeLogOutput) as unknown as T;
}
if (input !== null && typeof input === "object") {
const copy: Record<string, any> = {};
for (const [k, v] of Object.entries(input)) {
if (/key|secret|token|auth|password/i.test(k) && typeof v === "string") {
copy[k] = "[REDACTED]";
} else {
copy[k] = sanitizeLogOutput(v);
}
}
return copy as T;
}
return input;
}
/**
* Create a structured annotation feedback package from captured section data.
*/
export function createAnnotationFeedback(
feedback: Omit<AnnotationFeedback, "capturedAt"> & { capturedAt?: string }
): AnnotationFeedback {
return {
...feedback,
capturedAt: feedback.capturedAt || new Date().toISOString(),
};
}
/**
* Format structured visual annotations and requested UI changes into a
* clear, actionable prompt block that an agent can parse and execute.
*/
export function formatAnnotationFeedbackPrompt(feedback: AnnotationFeedback): string {
let output = `## Visual Feedback & Section Annotation\n\n`;
output += `- **Target URL**: ${feedback.url}\n`;
if (feedback.sessionId) {
output += `- **Session ID**: ${feedback.sessionId}\n`;
}
if (feedback.sectionSelector) {
output += `- **Section Target Selector**: \`${feedback.sectionSelector}\`\n`;
}
if (feedback.screenshotPath) {
output += `- **Screenshot Artifact**: \`${feedback.screenshotPath}\`\n`;
}
output += `- **Captured At**: ${feedback.capturedAt}\n\n`;
output += `### Summary\n${feedback.overallSummary}\n\n`;
if (feedback.annotations.length > 0) {
output += `### Annotated Elements & Notes\n\n`;
feedback.annotations.forEach((item, idx) => {
output += `${idx + 1}. **${item.label}**`;
if (item.severity) {
output += ` [${item.severity.toUpperCase()}]`;
}
output += `\n - **Note**: ${item.note}\n`;
if (item.selector) {
output += ` - **Selector**: \`${item.selector}\`\n`;
}
if (item.boundingBox) {
output += ` - **Region (Box)**: x=${item.boundingBox.x}, y=${item.boundingBox.y}, w=${item.boundingBox.width}, h=${item.boundingBox.height}\n`;
}
});
output += `\n`;
}
if (feedback.requestedChanges.length > 0) {
output += `### Actionable Change List\n\n`;
feedback.requestedChanges.forEach((change, idx) => {
output += `- [ ] ${change}\n`;
});
}
return output;
}
/**
* Steel Browser Session Manager & Handoff Orchestrator.
*/
export class SteelClient {
private config: SteelConfig;
private activeSessions = new Map<string, SteelSession>();
constructor(config?: SteelConfigOverrides) {
this.config = resolveSteelConfig(config);
}
getConfig(): Readonly<SteelConfig> {
return { ...this.config };
}
/**
* URL and headers for `chromium.connectOverCDP(url, { headers })`.
* The URL omits credentials when Authentik Basic auth is configured.
*/
cdpConnectOptions(session: Pick<SteelSession, "id" | "websocketUrl">): SteelCdpConnect {
return formatCDPConnect(session, this.config);
}
private headers(): Record<string, string> {
return {
"Content-Type": "application/json",
...steelRequestHeaders(this.config),
};
}
private async steelFetch(url: string, init: RequestInit): Promise<Response> {
const headers = {
...this.headers(),
...(init.headers as Record<string, string> | undefined),
};
const res = await fetch(url, { ...init, headers, redirect: "manual" });
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
throw steelAuthRedirectError(res);
}
return res;
}
/**
* Launch a new Steel browser session.
*/
async createSession(options?: CreateSessionOptions): Promise<SteelSession> {
const timeoutMs = options?.timeoutMs ?? this.config.timeoutMs ?? 300000;
const body: Record<string, any> = {
timeout: timeoutMs,
};
const dimensions = options?.dimensions || (options?.viewportPreset ? resolveViewportDimensions(options.viewportPreset) : undefined);
if (dimensions) {
body.dimensions = dimensions;
}
if (options?.userAgent) {
body.userAgent = options.userAgent;
}
if (options?.proxy) {
body.proxy = options.proxy;
}
const res = await this.steelFetch(`${this.config.apiUrl}/v1/sessions`, {
method: "POST",
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = sanitizeLogOutput(await res.text());
throw new Error(`Failed to create Steel session (${res.status}): ${errText}`);
}
const data = await res.json();
const session: SteelSession = {
id: data.id,
createdAt: data.createdAt || new Date().toISOString(),
status: "live",
state: "AGENT_CONTROL",
websocketUrl: data.websocketUrl || "",
debugUrl: data.debugUrl || "",
debuggerUrl: data.debuggerUrl || "",
sessionViewerUrl: data.sessionViewerUrl || `${this.config.uiUrl}?sessionId=${data.id}`,
timeoutMs,
lastActiveAt: Date.now(),
activeController: "agent",
userAgent: data.userAgent,
};
this.activeSessions.set(session.id, session);
return session;
}
/**
* Get details of an existing session.
*/
async getSession(sessionId: string): Promise<SteelSession | null> {
const res = await this.steelFetch(`${this.config.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
method: "GET",
});
if (res.status === 404) {
const cached = this.activeSessions.get(sessionId);
if (cached) {
cached.state = "EXPIRED";
cached.status = "released";
cached.activeController = "none";
}
return null;
}
if (!res.ok) {
throw new Error(`Failed to fetch Steel session (${res.status})`);
}
const data = await res.json();
const existing = this.activeSessions.get(sessionId);
const session: SteelSession = {
id: data.id,
createdAt: data.createdAt,
status: data.status,
state: existing?.state ?? (data.status === "live" ? "AGENT_CONTROL" : "RELEASED"),
websocketUrl: data.websocketUrl || existing?.websocketUrl || "",
debugUrl: data.debugUrl || existing?.debugUrl || "",
debuggerUrl: data.debuggerUrl || existing?.debuggerUrl || "",
sessionViewerUrl: existing?.sessionViewerUrl || `${this.config.uiUrl}?sessionId=${data.id}`,
timeoutMs: data.timeout || existing?.timeoutMs || 300000,
lastActiveAt: Date.now(),
activeController: existing?.activeController ?? (data.status === "live" ? "agent" : "none"),
userAgent: data.userAgent,
};
this.activeSessions.set(session.id, session);
return session;
}
/**
* Request human takeover for MFA, login, or consent.
* Pauses agent automation and sets state to HUMAN_CONTROL.
*/
requestHumanTakeover(sessionId: string, reason: string): {
session: SteelSession;
takeoverUrl: string;
instructions: string;
} {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not tracked or already released`);
}
if (session.state === "RELEASED" || session.state === "EXPIRED" || session.state === "FAILED") {
throw new Error(`Cannot initiate takeover on session in state ${session.state}`);
}
session.state = "HUMAN_CONTROL";
session.activeController = "human";
session.takeoverReason = reason;
session.lastActiveAt = Date.now();
const takeoverUrl = `${this.config.uiUrl}?sessionId=${encodeURIComponent(sessionId)}`;
const instructions =
`[HUMAN TAKEOVER REQUIRED]\n` +
`Reason: ${reason}\n` +
`Session ID: ${session.id}\n` +
`Takeover URL: ${takeoverUrl}\n\n` +
`Instructions for Operator:\n` +
`1. Open the URL above to access the session UI.\n` +
`2. Perform the required authentication / MFA / consent action.\n` +
`3. Return to the terminal and signal completion. Automation is paused until you confirm.`;
return {
session,
takeoverUrl,
instructions,
};
}
/**
* Signal that human takeover is complete.
* Moves state to VERIFY_AUTHENTICATION before transitioning back to AGENT_CONTROL.
*/
signalHumanComplete(sessionId: string): {
session: SteelSession;
state: SessionState;
} {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not tracked or already released`);
}
if (session.state !== "HUMAN_CONTROL") {
throw new Error(`Session ${sessionId} is not in HUMAN_CONTROL state (currently ${session.state})`);
}
session.state = "VERIFY_AUTHENTICATION";
session.activeController = "agent";
session.lastActiveAt = Date.now();
return {
session,
state: session.state,
};
}
/**
* Confirm authentication verification passed and restore AGENT_CONTROL.
*/
confirmAuthenticationVerified(sessionId: string): SteelSession {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not tracked or already released`);
}
if (session.state !== "VERIFY_AUTHENTICATION") {
throw new Error(`Session ${sessionId} is not in VERIFY_AUTHENTICATION state (currently ${session.state})`);
}
session.state = "AGENT_CONTROL";
session.activeController = "agent";
session.takeoverReason = undefined;
session.lastActiveAt = Date.now();
return session;
}
/**
* Gracefully release a session.
*/
async releaseSession(sessionId: string): Promise<boolean> {
try {
const res = await this.steelFetch(`${this.config.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}/release`, {
method: "POST",
});
const session = this.activeSessions.get(sessionId);
if (session) {
session.status = "released";
session.state = "RELEASED";
session.activeController = "none";
}
this.activeSessions.delete(sessionId);
return res.ok;
} catch (error) {
if (error instanceof SteelAuthRedirectError) throw error;
this.activeSessions.delete(sessionId);
return false;
}
}
/**
* Perform a direct stateless scrape without manual session management.
*/
async scrape(url: string): Promise<ScrapeResult> {
const res = await this.steelFetch(`${this.config.apiUrl}/v1/scrape`, {
method: "POST",
body: JSON.stringify({ url }),
});
if (!res.ok) {
const err = sanitizeLogOutput(await res.text());
throw new Error(`Scrape failed (${res.status}): ${err}`);
}
return res.json();
}
/**
* Perform a direct screenshot action.
*/
async screenshot(url: string, fullPage = false): Promise<ScreenshotResult> {
const res = await this.steelFetch(`${this.config.apiUrl}/v1/screenshot`, {
method: "POST",
body: JSON.stringify({ url, fullPage }),
});
if (!res.ok) {
const err = sanitizeLogOutput(await res.text());
throw new Error(`Screenshot failed (${res.status}): ${err}`);
}
return res.json();
}
/**
* Detect and list orphaned or timed-out active sessions.
*/
async checkOrphanedSessions(maxIdleMs = 600000): Promise<string[]> {
const res = await this.steelFetch(`${this.config.apiUrl}/v1/sessions`, {
method: "GET",
});
if (!res.ok) {
return [];
}
const data = await res.json();
const remoteSessions: Array<{ id: string; status: string; createdAt: string; duration: number }> =
data.sessions || [];
const now = Date.now();
const orphaned: string[] = [];
for (const rs of remoteSessions) {
if (rs.status === "live" || rs.status === "idle") {
const tracked = this.activeSessions.get(rs.id);
if (!tracked && rs.duration > maxIdleMs) {
orphaned.push(rs.id);
} else if (tracked && now - tracked.lastActiveAt > maxIdleMs && tracked.state !== "HUMAN_CONTROL") {
orphaned.push(rs.id);
}
}
}
return orphaned;
}
}