@kontextmind/kxm 0.7.133 → 0.7.135

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.
@@ -6,6 +6,10 @@
6
6
  * takeover, MFA, and the live session viewer (`KXM_BROWSER=steel`).
7
7
  * Manages remote Steel sessions, CDP endpoints, human takeover handoffs,
8
8
  * pass-cli credential references, and automated cleanup without leaking secrets.
9
+ *
10
+ * Hosts behind Authentik forward auth (the KontextMind Steel proxies) accept
11
+ * an app password only as `Authorization: Basic`. A Bearer token is refused.
12
+ * `STEEL_API_KEY` remains a legacy shim: `x-steel-api-key` and `?apiKey=`.
9
13
  */
10
14
 
11
15
  import { execSync } from "node:child_process";
@@ -136,11 +140,156 @@ export interface AnnotationFeedback {
136
140
 
137
141
  export interface SteelConfig {
138
142
  apiUrl: string;
143
+ /** Legacy Steel key. Omitted once Authentik Basic auth is configured. */
139
144
  apiKey?: string | undefined;
145
+ /**
146
+ * Full `Authorization` value, for example `Basic <base64>`.
147
+ * Present when Authentik app-password auth is configured.
148
+ */
149
+ authorization?: string | undefined;
140
150
  uiUrl?: string | undefined;
141
151
  timeoutMs?: number | undefined;
142
152
  }
143
153
 
154
+ /** Inputs for {@link resolveSteelConfig}. Environment variables fill anything omitted. */
155
+ export interface SteelConfigOverrides extends Partial<SteelConfig> {
156
+ /** Full Authorization value, or a bare base64 credential. Wins over the other auth inputs. */
157
+ authHeader?: string | undefined;
158
+ /** `base64(user:token)`, with or without a leading `Basic `. */
159
+ authBasic?: string | undefined;
160
+ /** Authentik username, for example `svc-steel`. Used with `authToken`. */
161
+ authUser?: string | undefined;
162
+ /** Authentik app password. Used with `authUser`. */
163
+ authToken?: string | undefined;
164
+ }
165
+
166
+ export class SteelAuthConfigError extends Error {
167
+ constructor(message: string) {
168
+ super(message);
169
+ this.name = "SteelAuthConfigError";
170
+ }
171
+ }
172
+
173
+ /** Authentik challenged the request with a redirect. The message never includes credentials. */
174
+ export class SteelAuthRedirectError extends Error {
175
+ readonly status: number;
176
+ readonly host: string;
177
+
178
+ constructor(status: number, host: string) {
179
+ super(
180
+ `Steel request was redirected (${status}) to ${host}. ` +
181
+ "Send Authorization: Basic via STEEL_AUTH_BASIC or STEEL_AUTH_USER and STEEL_AUTH_TOKEN. " +
182
+ "A Bearer token is not accepted.",
183
+ );
184
+ this.name = "SteelAuthRedirectError";
185
+ this.status = status;
186
+ this.host = host;
187
+ }
188
+ }
189
+
190
+ const LEGACY_STEEL_AUTH_WARNING =
191
+ "kxm: STEEL_API_KEY is deprecated for Steel. Authentik forward auth accepts app passwords only as Authorization: Basic. " +
192
+ "Set STEEL_AUTH_BASIC, or STEEL_AUTH_USER and STEEL_AUTH_TOKEN. " +
193
+ "The legacy x-steel-api-key header and apiKey query parameter remain for the temporary proxy shim.\n";
194
+
195
+ let legacySteelAuthWarned = false;
196
+
197
+ /** Test hook. Production calls warn at most once per process. */
198
+ export function resetLegacySteelAuthWarningForTests(): void {
199
+ legacySteelAuthWarned = false;
200
+ }
201
+
202
+ function warnLegacySteelAuth(): void {
203
+ if (legacySteelAuthWarned) return;
204
+ legacySteelAuthWarned = true;
205
+ process.stderr.write(LEGACY_STEEL_AUTH_WARNING);
206
+ }
207
+
208
+ function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
209
+ for (const value of values) {
210
+ const trimmed = value?.trim();
211
+ if (trimmed) return trimmed;
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ function normalizeAuthorization(raw: string): string {
217
+ if (/[\r\n]/.test(raw)) {
218
+ throw new SteelAuthConfigError("Steel authorization value contains a line break.");
219
+ }
220
+ const value = raw.trim();
221
+ if (!value) {
222
+ throw new SteelAuthConfigError("Steel authorization value is empty.");
223
+ }
224
+ const basicPrefix = /^basic\s+(.+)$/i.exec(value);
225
+ if (basicPrefix) {
226
+ const credential = basicPrefix[1] ?? "";
227
+ if (!credential || /\s/.test(credential)) {
228
+ throw new SteelAuthConfigError("Steel Basic credential must be a single base64 token.");
229
+ }
230
+ return `Basic ${credential}`;
231
+ }
232
+ if (/\s/.test(value)) {
233
+ return value;
234
+ }
235
+ return `Basic ${value}`;
236
+ }
237
+
238
+ /**
239
+ * Resolve the Authentik `Authorization` value.
240
+ * Precedence: auth header override, then `STEEL_AUTH_BASIC`, then user + token.
241
+ * Returns undefined when none of those are set so the legacy API key can apply.
242
+ */
243
+ export function resolveSteelAuthorization(overrides?: SteelConfigOverrides): string | undefined {
244
+ const header = firstNonEmpty(overrides?.authHeader, overrides?.authorization, process.env.STEEL_AUTH_HEADER);
245
+ if (header) return normalizeAuthorization(header);
246
+
247
+ const basic = firstNonEmpty(overrides?.authBasic, process.env.STEEL_AUTH_BASIC);
248
+ if (basic) return normalizeAuthorization(basic);
249
+
250
+ const user = firstNonEmpty(overrides?.authUser, process.env.STEEL_AUTH_USER);
251
+ const token = firstNonEmpty(overrides?.authToken, process.env.STEEL_AUTH_TOKEN);
252
+ if (user || token) {
253
+ if (!user || !token) {
254
+ throw new SteelAuthConfigError(
255
+ "Steel Basic auth needs both STEEL_AUTH_USER and STEEL_AUTH_TOKEN, or STEEL_AUTH_BASIC.",
256
+ );
257
+ }
258
+ return `Basic ${Buffer.from(`${user}:${token}`, "utf8").toString("base64")}`;
259
+ }
260
+ return undefined;
261
+ }
262
+
263
+ /**
264
+ * Headers for Steel HTTP and for Playwright `chromium.connectOverCDP(url, { headers })`.
265
+ * Basic auth wins and does not attach the legacy API key.
266
+ */
267
+ export function steelRequestHeaders(config: Pick<SteelConfig, "authorization" | "apiKey">): Record<string, string> {
268
+ if (config.authorization) {
269
+ return { Authorization: config.authorization };
270
+ }
271
+ if (config.apiKey) {
272
+ return { "x-steel-api-key": config.apiKey };
273
+ }
274
+ return {};
275
+ }
276
+
277
+ export function steelAuthRedirectError(res: {
278
+ status: number;
279
+ headers: { get(name: string): string | null };
280
+ }): SteelAuthRedirectError {
281
+ let host = "the identity provider";
282
+ const location = res.headers.get("location");
283
+ if (location) {
284
+ try {
285
+ host = new URL(location, "https://id.kxmd.dev").host;
286
+ } catch {
287
+ host = "the identity provider";
288
+ }
289
+ }
290
+ return new SteelAuthRedirectError(res.status, host);
291
+ }
292
+
144
293
  export function resolvePassCliApiKey(
145
294
  execFn: (cmd: string) => string = (cmd) =>
146
295
  execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], timeout: 5000 }),
@@ -178,14 +327,24 @@ export function resolvePassCliApiKey(
178
327
  /**
179
328
  * Resolve Steel configuration from environment or pass-cli.
180
329
  * Does not write secrets to disk or logs.
330
+ *
331
+ * Authentik Basic auth (`STEEL_AUTH_HEADER`, `STEEL_AUTH_BASIC`, or
332
+ * `STEEL_AUTH_USER` + `STEEL_AUTH_TOKEN`) wins over `STEEL_API_KEY`.
333
+ * The legacy key is kept only when no Basic credential is configured, and
334
+ * a one-time deprecation warning is written to stderr.
181
335
  */
182
- export function resolveSteelConfig(overrides?: Partial<SteelConfig>): SteelConfig {
336
+ export function resolveSteelConfig(overrides?: SteelConfigOverrides): SteelConfig {
183
337
  const apiUrl =
184
338
  overrides?.apiUrl ||
185
339
  process.env.STEEL_API_URL ||
186
340
  "https://steel.kontextmind.com";
187
341
 
188
- const apiKey = overrides?.apiKey || process.env.STEEL_API_KEY || resolvePassCliApiKey();
342
+ const authorization = resolveSteelAuthorization(overrides);
343
+ let apiKey: string | undefined;
344
+ if (!authorization) {
345
+ apiKey = overrides?.apiKey || process.env.STEEL_API_KEY || resolvePassCliApiKey();
346
+ if (apiKey) warnLegacySteelAuth();
347
+ }
189
348
 
190
349
  const uiUrl =
191
350
  overrides?.uiUrl ||
@@ -196,6 +355,7 @@ export function resolveSteelConfig(overrides?: Partial<SteelConfig>): SteelConfi
196
355
  return {
197
356
  apiUrl: apiUrl.replace(/\/$/, ""),
198
357
  apiKey,
358
+ authorization,
199
359
  uiUrl,
200
360
  timeoutMs: overrides?.timeoutMs || 300000, // 5 minutes default
201
361
  };
@@ -203,6 +363,7 @@ export function resolveSteelConfig(overrides?: Partial<SteelConfig>): SteelConfi
203
363
 
204
364
  /**
205
365
  * Format a remote CDP connection URL for Playwright or agent-browser.
366
+ * The legacy `apiKey` query parameter is added only when Basic auth is unset.
206
367
  */
207
368
  export function formatCDPEndpoint(session: Pick<SteelSession, "id" | "websocketUrl">, config: SteelConfig): string {
208
369
  const baseApi = config.apiUrl;
@@ -213,13 +374,34 @@ export function formatCDPEndpoint(session: Pick<SteelSession, "id" | "websocketU
213
374
 
214
375
  const searchParams = new URLSearchParams();
215
376
  searchParams.set("sessionId", session.id);
216
- if (config.apiKey) {
377
+ if (!config.authorization && config.apiKey) {
217
378
  searchParams.set("apiKey", config.apiKey);
218
379
  }
219
380
 
220
381
  return `${wsProtocol}//${host}/v1/devtools?${searchParams.toString()}`;
221
382
  }
222
383
 
384
+ export interface SteelCdpConnect {
385
+ /** WebSocket URL. Credentials stay out of it when Authentik Basic auth is set. */
386
+ url: string;
387
+ /** Pass as the second argument to `chromium.connectOverCDP(url, { headers })`. */
388
+ headers: Record<string, string>;
389
+ }
390
+
391
+ /**
392
+ * CDP URL plus the headers Playwright must send on the WebSocket handshake.
393
+ * Call `chromium.connectOverCDP(url, { headers })`. Do not put the credential in the URL.
394
+ */
395
+ export function formatCDPConnect(
396
+ session: Pick<SteelSession, "id" | "websocketUrl">,
397
+ config: SteelConfig,
398
+ ): SteelCdpConnect {
399
+ return {
400
+ url: formatCDPEndpoint(session, config),
401
+ headers: steelRequestHeaders(config),
402
+ };
403
+ }
404
+
223
405
  export const DEFAULT_OBSCURA_CDP_URL = "http://127.0.0.1:9222";
224
406
  const DEFAULT_OBSCURA_PORT = 9222;
225
407
 
@@ -249,32 +431,64 @@ export function resolveObscuraCdpEndpoint(): string {
249
431
  }
250
432
 
251
433
  /**
252
- * Playwright CDP endpoint.
253
- * Obscura by default. `KXM_BROWSER=steel` uses {@link formatCDPEndpoint} for `session`.
434
+ * Playwright CDP target.
435
+ * Obscura by default, with empty headers. `KXM_BROWSER=steel` uses {@link formatCDPConnect}.
254
436
  */
255
- export function resolveBrowserCdpEndpoint(
437
+ export function resolveBrowserCdpConnect(
256
438
  session?: Pick<SteelSession, "id" | "websocketUrl">,
257
439
  config?: SteelConfig,
258
- ): string {
440
+ ): SteelCdpConnect {
259
441
  const browser = (process.env.KXM_BROWSER ?? "").trim().toLowerCase();
260
442
  if (browser === "" || browser === "obscura") {
261
- return resolveObscuraCdpEndpoint();
443
+ return { url: resolveObscuraCdpEndpoint(), headers: {} };
262
444
  }
263
445
  if (browser === "steel") {
264
446
  if (!session?.id) {
265
447
  throw new Error("KXM_BROWSER=steel requires a Steel session id");
266
448
  }
267
- return formatCDPEndpoint(session, config ?? resolveSteelConfig());
449
+ return formatCDPConnect(session, config ?? resolveSteelConfig());
268
450
  }
269
451
  throw new Error(`Unsupported KXM_BROWSER value ${JSON.stringify(process.env.KXM_BROWSER)}; expected "obscura" or "steel"`);
270
452
  }
271
453
 
454
+ /**
455
+ * Playwright CDP endpoint.
456
+ * Obscura by default. `KXM_BROWSER=steel` returns the URL from {@link formatCDPConnect}.
457
+ * Pass {@link resolveBrowserCdpConnect} headers into `chromium.connectOverCDP`, or call {@link connectBrowserOverCdp}.
458
+ */
459
+ export function resolveBrowserCdpEndpoint(
460
+ session?: Pick<SteelSession, "id" | "websocketUrl">,
461
+ config?: SteelConfig,
462
+ ): string {
463
+ return resolveBrowserCdpConnect(session, config).url;
464
+ }
465
+
466
+ /**
467
+ * Connect Playwright over CDP.
468
+ * Obscura is called with the URL only. `KXM_BROWSER=steel` passes Authentik or legacy Steel headers on the handshake.
469
+ */
470
+ export async function connectBrowserOverCdp<T>(
471
+ connectOverCDP: (url: string, options?: { headers?: Record<string, string> }) => Promise<T>,
472
+ session?: Pick<SteelSession, "id" | "websocketUrl">,
473
+ config?: SteelConfig,
474
+ ): Promise<T> {
475
+ const { url, headers } = resolveBrowserCdpConnect(session, config);
476
+ if (Object.keys(headers).length === 0) return connectOverCDP(url);
477
+ return connectOverCDP(url, { headers });
478
+ }
479
+
272
480
  /**
273
481
  * Redact sensitive API keys and tokens from URLs and objects for logging.
274
482
  */
275
483
  export function sanitizeLogOutput<T>(input: T): T {
276
484
  if (typeof input === "string") {
277
- return input.replace(/apiKey=[^&]+/g, "apiKey=[REDACTED]").replace(/steel_[a-f0-9]+/g, "steel_[REDACTED]") as unknown as T;
485
+ const redacted = input
486
+ .replace(/apiKey=[^&\s]+/gi, "apiKey=[REDACTED]")
487
+ .replace(/([?&]authorization=)[^&\s]+/gi, "$1[REDACTED]")
488
+ .replace(/authorization:\s*(?:basic\s+)?\S+/gi, "authorization: [REDACTED]")
489
+ .replace(/\bBasic\s+(?:[A-Za-z0-9+/]*[+/=0-9][A-Za-z0-9+/]*={0,2})/g, "Basic [REDACTED]")
490
+ .replace(/steel_[a-f0-9]+/g, "steel_[REDACTED]");
491
+ return redacted as unknown as T;
278
492
  }
279
493
  if (Array.isArray(input)) {
280
494
  return input.map(sanitizeLogOutput) as unknown as T;
@@ -360,7 +574,7 @@ export class SteelClient {
360
574
  private config: SteelConfig;
361
575
  private activeSessions = new Map<string, SteelSession>();
362
576
 
363
- constructor(config?: Partial<SteelConfig>) {
577
+ constructor(config?: SteelConfigOverrides) {
364
578
  this.config = resolveSteelConfig(config);
365
579
  }
366
580
 
@@ -368,14 +582,31 @@ export class SteelClient {
368
582
  return { ...this.config };
369
583
  }
370
584
 
585
+ /**
586
+ * URL and headers for `chromium.connectOverCDP(url, { headers })`.
587
+ * The URL omits credentials when Authentik Basic auth is configured.
588
+ */
589
+ cdpConnectOptions(session: Pick<SteelSession, "id" | "websocketUrl">): SteelCdpConnect {
590
+ return formatCDPConnect(session, this.config);
591
+ }
592
+
371
593
  private headers(): Record<string, string> {
372
- const h: Record<string, string> = {
594
+ return {
373
595
  "Content-Type": "application/json",
596
+ ...steelRequestHeaders(this.config),
374
597
  };
375
- if (this.config.apiKey) {
376
- h["x-steel-api-key"] = this.config.apiKey;
598
+ }
599
+
600
+ private async steelFetch(url: string, init: RequestInit): Promise<Response> {
601
+ const headers = {
602
+ ...this.headers(),
603
+ ...(init.headers as Record<string, string> | undefined),
604
+ };
605
+ const res = await fetch(url, { ...init, headers, redirect: "manual" });
606
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
607
+ throw steelAuthRedirectError(res);
377
608
  }
378
- return h;
609
+ return res;
379
610
  }
380
611
 
381
612
  /**
@@ -397,14 +628,13 @@ export class SteelClient {
397
628
  body.proxy = options.proxy;
398
629
  }
399
630
 
400
- const res = await fetch(`${this.config.apiUrl}/v1/sessions`, {
631
+ const res = await this.steelFetch(`${this.config.apiUrl}/v1/sessions`, {
401
632
  method: "POST",
402
- headers: this.headers(),
403
633
  body: JSON.stringify(body),
404
634
  });
405
635
 
406
636
  if (!res.ok) {
407
- const errText = await res.text();
637
+ const errText = sanitizeLogOutput(await res.text());
408
638
  throw new Error(`Failed to create Steel session (${res.status}): ${errText}`);
409
639
  }
410
640
 
@@ -432,9 +662,8 @@ export class SteelClient {
432
662
  * Get details of an existing session.
433
663
  */
434
664
  async getSession(sessionId: string): Promise<SteelSession | null> {
435
- const res = await fetch(`${this.config.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
665
+ const res = await this.steelFetch(`${this.config.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
436
666
  method: "GET",
437
- headers: this.headers(),
438
667
  });
439
668
 
440
669
  if (res.status === 404) {
@@ -563,9 +792,8 @@ export class SteelClient {
563
792
  */
564
793
  async releaseSession(sessionId: string): Promise<boolean> {
565
794
  try {
566
- const res = await fetch(`${this.config.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}/release`, {
795
+ const res = await this.steelFetch(`${this.config.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}/release`, {
567
796
  method: "POST",
568
- headers: this.headers(),
569
797
  });
570
798
 
571
799
  const session = this.activeSessions.get(sessionId);
@@ -576,7 +804,8 @@ export class SteelClient {
576
804
  }
577
805
  this.activeSessions.delete(sessionId);
578
806
  return res.ok;
579
- } catch {
807
+ } catch (error) {
808
+ if (error instanceof SteelAuthRedirectError) throw error;
580
809
  this.activeSessions.delete(sessionId);
581
810
  return false;
582
811
  }
@@ -586,14 +815,13 @@ export class SteelClient {
586
815
  * Perform a direct stateless scrape without manual session management.
587
816
  */
588
817
  async scrape(url: string): Promise<ScrapeResult> {
589
- const res = await fetch(`${this.config.apiUrl}/v1/scrape`, {
818
+ const res = await this.steelFetch(`${this.config.apiUrl}/v1/scrape`, {
590
819
  method: "POST",
591
- headers: this.headers(),
592
820
  body: JSON.stringify({ url }),
593
821
  });
594
822
 
595
823
  if (!res.ok) {
596
- const err = await res.text();
824
+ const err = sanitizeLogOutput(await res.text());
597
825
  throw new Error(`Scrape failed (${res.status}): ${err}`);
598
826
  }
599
827
 
@@ -604,14 +832,13 @@ export class SteelClient {
604
832
  * Perform a direct screenshot action.
605
833
  */
606
834
  async screenshot(url: string, fullPage = false): Promise<ScreenshotResult> {
607
- const res = await fetch(`${this.config.apiUrl}/v1/screenshot`, {
835
+ const res = await this.steelFetch(`${this.config.apiUrl}/v1/screenshot`, {
608
836
  method: "POST",
609
- headers: this.headers(),
610
837
  body: JSON.stringify({ url, fullPage }),
611
838
  });
612
839
 
613
840
  if (!res.ok) {
614
- const err = await res.text();
841
+ const err = sanitizeLogOutput(await res.text());
615
842
  throw new Error(`Screenshot failed (${res.status}): ${err}`);
616
843
  }
617
844
 
@@ -622,9 +849,8 @@ export class SteelClient {
622
849
  * Detect and list orphaned or timed-out active sessions.
623
850
  */
624
851
  async checkOrphanedSessions(maxIdleMs = 600000): Promise<string[]> {
625
- const res = await fetch(`${this.config.apiUrl}/v1/sessions`, {
852
+ const res = await this.steelFetch(`${this.config.apiUrl}/v1/sessions`, {
626
853
  method: "GET",
627
- headers: this.headers(),
628
854
  });
629
855
 
630
856
  if (!res.ok) {
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.133";
14
+ const VERSION = "0.7.135";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();