feat: enhance sensitive data handling in config forms
- Updated config form tests to ensure sensitive values are properly managed and revealed based on user interactions. - Refactored sensitive input rendering logic to support toggling visibility and redaction based on stream mode. - Improved state management for sensitive paths, allowing for better control over when sensitive data is displayed. - Added utility functions to identify and handle sensitive configuration data throughout the application. - Enhanced UI components to reflect changes in sensitive data handling, ensuring a consistent user experience.
This commit is contained in:
parent
1e440712fb
commit
39020f8d62
@ -51,7 +51,7 @@ describe("config form renderer", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
const tokenInput: HTMLInputElement | null = container.querySelector("input[type='password']");
|
||||
const tokenInput: HTMLInputElement | null = container.querySelector(".cfg-input");
|
||||
expect(tokenInput).not.toBeNull();
|
||||
if (!tokenInput) {
|
||||
return;
|
||||
@ -77,6 +77,81 @@ describe("config form renderer", () => {
|
||||
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
|
||||
});
|
||||
|
||||
it("keeps sensitive values out of hidden form inputs until revealed", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
const revealed = new Set<string>();
|
||||
const props = {
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { gateway: { auth: { token: "secret-123" } } },
|
||||
streamMode: false,
|
||||
isSensitivePathRevealed: (path: Array<string | number>) =>
|
||||
revealed.has(
|
||||
path.filter((segment): segment is string => typeof segment === "string").join("."),
|
||||
),
|
||||
onToggleSensitivePath: (path: Array<string | number>) => {
|
||||
const key = path
|
||||
.filter((segment): segment is string => typeof segment === "string")
|
||||
.join(".");
|
||||
if (revealed.has(key)) {
|
||||
revealed.delete(key);
|
||||
} else {
|
||||
revealed.add(key);
|
||||
}
|
||||
},
|
||||
onPatch,
|
||||
};
|
||||
|
||||
render(renderConfigForm(props), container);
|
||||
const hiddenInput = container.querySelector<HTMLInputElement>(".cfg-input");
|
||||
expect(hiddenInput).not.toBeNull();
|
||||
expect(hiddenInput?.value).toBe("");
|
||||
expect(hiddenInput?.placeholder).toContain("redacted");
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Reveal value"]');
|
||||
expect(toggle?.disabled).toBe(false);
|
||||
toggle?.click();
|
||||
|
||||
render(renderConfigForm(props), container);
|
||||
const revealedInput = container.querySelector<HTMLInputElement>(".cfg-input");
|
||||
expect(revealedInput?.value).toBe("secret-123");
|
||||
expect(revealedInput?.type).toBe("text");
|
||||
});
|
||||
|
||||
it("blocks sensitive field reveal while stream mode is enabled", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { gateway: { auth: { token: "secret-123" } } },
|
||||
streamMode: true,
|
||||
isSensitivePathRevealed: () => false,
|
||||
onToggleSensitivePath: vi.fn(),
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(".cfg-input");
|
||||
expect(input?.value).toBe("");
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Disable stream mode to reveal value"]',
|
||||
);
|
||||
expect(toggle?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("adds and removes array entries", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
@ -301,7 +376,7 @@ describe("config form renderer", () => {
|
||||
}),
|
||||
noMatchContainer,
|
||||
);
|
||||
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
|
||||
expect(noMatchContainer.textContent).not.toContain("Token");
|
||||
});
|
||||
|
||||
it("supports SecretInput unions in additionalProperties maps", () => {
|
||||
@ -366,12 +441,17 @@ describe("config form renderer", () => {
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { models: { providers: { openai: { apiKey: "old" } } } },
|
||||
streamMode: false,
|
||||
isSensitivePathRevealed: () => true,
|
||||
onToggleSensitivePath: vi.fn(),
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const apiKeyInput: HTMLInputElement | null = container.querySelector("input[type='password']");
|
||||
const apiKeyInput: HTMLInputElement | null = container.querySelector(
|
||||
".cfg-input:not(.cfg-input--sm)",
|
||||
);
|
||||
expect(apiKeyInput).not.toBeNull();
|
||||
if (!apiKeyInput) {
|
||||
return;
|
||||
|
||||
@ -14,6 +14,7 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn>
|
||||
agentsList: null,
|
||||
agentsSelectedId: "main",
|
||||
toolsCatalogLoading: false,
|
||||
toolsCatalogLoadingAgentId: null,
|
||||
toolsCatalogError: null,
|
||||
toolsCatalogResult: null,
|
||||
};
|
||||
@ -58,4 +59,68 @@ describe("loadToolsCatalog", () => {
|
||||
expect(state.toolsCatalogError).toContain("gateway unavailable");
|
||||
expect(state.toolsCatalogLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a new agent request to replace a stale in-flight load", async () => {
|
||||
const { state, request } = createState();
|
||||
|
||||
let resolveMain:
|
||||
| ((value: {
|
||||
agentId: string;
|
||||
profiles: { id: string; label: string }[];
|
||||
groups: {
|
||||
id: string;
|
||||
label: string;
|
||||
source: string;
|
||||
tools: { id: string; label: string; description: string; source: string }[];
|
||||
}[];
|
||||
}) => void)
|
||||
| null = null;
|
||||
const mainRequest = new Promise<{
|
||||
agentId: string;
|
||||
profiles: { id: string; label: string }[];
|
||||
groups: {
|
||||
id: string;
|
||||
label: string;
|
||||
source: string;
|
||||
tools: { id: string; label: string; description: string; source: string }[];
|
||||
}[];
|
||||
}>((resolve) => {
|
||||
resolveMain = resolve;
|
||||
});
|
||||
|
||||
const replacementPayload = {
|
||||
agentId: "other",
|
||||
profiles: [{ id: "full", label: "Full" }],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
request.mockImplementationOnce(() => mainRequest).mockResolvedValueOnce(replacementPayload);
|
||||
|
||||
const initialLoad = loadToolsCatalog(state, "main");
|
||||
await Promise.resolve();
|
||||
|
||||
state.agentsSelectedId = "other";
|
||||
await loadToolsCatalog(state, "other");
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "tools.catalog", {
|
||||
agentId: "main",
|
||||
includePlugins: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "tools.catalog", {
|
||||
agentId: "other",
|
||||
includePlugins: true,
|
||||
});
|
||||
expect(state.toolsCatalogResult).toEqual(replacementPayload);
|
||||
expect(state.toolsCatalogLoading).toBe(false);
|
||||
|
||||
resolveMain?.({
|
||||
agentId: "main",
|
||||
profiles: [{ id: "full", label: "Full" }],
|
||||
groups: [],
|
||||
});
|
||||
await initialLoad;
|
||||
|
||||
expect(state.toolsCatalogResult).toEqual(replacementPayload);
|
||||
expect(state.toolsCatalogLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ export type AgentsState = {
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsSelectedId: string | null;
|
||||
toolsCatalogLoading: boolean;
|
||||
toolsCatalogLoadingAgentId?: string | null;
|
||||
toolsCatalogError: string | null;
|
||||
toolsCatalogResult: ToolsCatalogResult | null;
|
||||
};
|
||||
@ -44,10 +45,11 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) {
|
||||
if (!state.client || !state.connected || !resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (state.toolsCatalogLoading) {
|
||||
if (state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
state.toolsCatalogLoading = true;
|
||||
state.toolsCatalogLoadingAgentId = resolvedAgentId;
|
||||
state.toolsCatalogError = null;
|
||||
state.toolsCatalogResult = null;
|
||||
try {
|
||||
@ -55,18 +57,25 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) {
|
||||
agentId: resolvedAgentId,
|
||||
includePlugins: true,
|
||||
});
|
||||
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
state.toolsCatalogResult = res;
|
||||
} catch (err) {
|
||||
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
state.toolsCatalogResult = null;
|
||||
state.toolsCatalogError = String(err);
|
||||
} finally {
|
||||
if (!state.agentsSelectedId || state.agentsSelectedId === resolvedAgentId) {
|
||||
if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
|
||||
state.toolsCatalogLoadingAgentId = null;
|
||||
state.toolsCatalogLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,6 +209,50 @@ describe("applyConfig", () => {
|
||||
expect(params.baseHash).toBe("hash-apply-1");
|
||||
expect(params.sessionKey).toBe("agent:main:web:dm:test");
|
||||
});
|
||||
|
||||
it("preserves sensitive form values in serialized raw state for apply", async () => {
|
||||
const request = createRequestWithConfigGet();
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.applySessionKey = "agent:main:web:dm:secret";
|
||||
state.configFormMode = "form";
|
||||
state.configForm = {
|
||||
gateway: {
|
||||
auth: {
|
||||
token: "secret-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
state.configSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
gateway: {
|
||||
type: "object",
|
||||
properties: {
|
||||
auth: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
state.configSnapshot = { hash: "hash-apply-secret" };
|
||||
|
||||
await applyConfig(state);
|
||||
|
||||
expect(request.mock.calls[0]?.[0]).toBe("config.apply");
|
||||
const params = request.mock.calls[0]?.[1] as {
|
||||
raw: string;
|
||||
baseHash: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
expect(params.raw).toContain("secret-123");
|
||||
expect(params.baseHash).toBe("hash-apply-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveConfig", () => {
|
||||
|
||||
@ -6,7 +6,7 @@ import type {
|
||||
SessionsListResultBase,
|
||||
SessionsPatchResultBase,
|
||||
} from "../../../src/shared/session-types.js";
|
||||
export type { ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
|
||||
export type { ConfigUiHint, ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
|
||||
|
||||
export type ChannelsStatusSnapshot = {
|
||||
ts: number;
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { icons as sharedIcons } from "../icons.ts";
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import {
|
||||
defaultValue,
|
||||
hasSensitiveConfigData,
|
||||
hintForPath,
|
||||
humanize,
|
||||
pathKey,
|
||||
REDACTED_PLACEHOLDER,
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared.ts";
|
||||
@ -100,11 +103,79 @@ type FieldMeta = {
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type SensitiveRenderParams = {
|
||||
path: Array<string | number>;
|
||||
value: unknown;
|
||||
hints: ConfigUiHints;
|
||||
streamMode: boolean;
|
||||
revealSensitive: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
};
|
||||
|
||||
type SensitiveRenderState = {
|
||||
isSensitive: boolean;
|
||||
isRedacted: boolean;
|
||||
isRevealed: boolean;
|
||||
canReveal: boolean;
|
||||
};
|
||||
|
||||
export type ConfigSearchCriteria = {
|
||||
text: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState {
|
||||
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
|
||||
const isRevealed =
|
||||
isSensitive &&
|
||||
!params.streamMode &&
|
||||
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
|
||||
return {
|
||||
isSensitive,
|
||||
isRedacted: isSensitive && !isRevealed,
|
||||
isRevealed,
|
||||
canReveal: isSensitive && !params.streamMode,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSensitiveToggleButton(params: {
|
||||
path: Array<string | number>;
|
||||
state: SensitiveRenderState;
|
||||
disabled: boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
}): TemplateResult | typeof nothing {
|
||||
const { state } = params;
|
||||
if (!state.isSensitive || !params.onToggleSensitivePath) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
|
||||
style="width:28px;height:28px;padding:0;"
|
||||
title=${
|
||||
state.canReveal
|
||||
? state.isRevealed
|
||||
? "Hide value"
|
||||
: "Reveal value"
|
||||
: "Disable stream mode to reveal value"
|
||||
}
|
||||
aria-label=${
|
||||
state.canReveal
|
||||
? state.isRevealed
|
||||
? "Hide value"
|
||||
: "Reveal value"
|
||||
: "Disable stream mode to reveal value"
|
||||
}
|
||||
aria-pressed=${state.isRevealed}
|
||||
?disabled=${params.disabled || !state.canReveal}
|
||||
@click=${() => params.onToggleSensitivePath?.(params.path)}
|
||||
>
|
||||
${state.isRevealed ? sharedIcons.eye : sharedIcons.eyeOff}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
|
||||
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
|
||||
}
|
||||
@ -331,6 +402,10 @@ export function renderNode(params: {
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
streamMode?: boolean;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult | typeof nothing {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||
@ -442,7 +517,19 @@ export function renderNode(params: {
|
||||
}
|
||||
|
||||
// Complex union (e.g. array | object) — render as JSON textarea
|
||||
return renderJsonTextarea({ schema, value, path, hints, disabled, showLabel, onPatch });
|
||||
return renderJsonTextarea({
|
||||
schema,
|
||||
value,
|
||||
path,
|
||||
hints,
|
||||
disabled,
|
||||
showLabel,
|
||||
streamMode: params.streamMode ?? false,
|
||||
revealSensitive: params.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||
onPatch,
|
||||
});
|
||||
}
|
||||
|
||||
// Enum - use segmented for small, dropdown for large
|
||||
@ -540,6 +627,10 @@ function renderTextInput(params: {
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
streamMode?: boolean;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
inputType: "text" | "number";
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
@ -547,17 +638,23 @@ function renderTextInput(params: {
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const isSensitive =
|
||||
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
|
||||
const placeholder =
|
||||
hint?.placeholder ??
|
||||
// oxlint-disable typescript/no-base-to-string
|
||||
(isSensitive
|
||||
? "••••"
|
||||
: schema.default !== undefined
|
||||
? `Default: ${String(schema.default)}`
|
||||
: "");
|
||||
const displayValue = value ?? "";
|
||||
const sensitiveState = getSensitiveRenderState({
|
||||
path,
|
||||
value,
|
||||
hints,
|
||||
streamMode: params.streamMode ?? false,
|
||||
revealSensitive: params.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||
});
|
||||
const placeholder = sensitiveState.isRedacted
|
||||
? REDACTED_PLACEHOLDER
|
||||
: (hint?.placeholder ??
|
||||
// oxlint-disable typescript/no-base-to-string
|
||||
(schema.default !== undefined ? `Default: ${String(schema.default)}` : ""));
|
||||
const displayValue = sensitiveState.isRedacted ? "" : (value ?? "");
|
||||
const effectiveDisabled = disabled || sensitiveState.isRedacted;
|
||||
const effectiveInputType =
|
||||
sensitiveState.isSensitive && !sensitiveState.isRedacted ? "text" : inputType;
|
||||
|
||||
return html`
|
||||
<div class="cfg-field">
|
||||
@ -566,12 +663,16 @@ function renderTextInput(params: {
|
||||
${renderTags(tags)}
|
||||
<div class="cfg-input-wrap">
|
||||
<input
|
||||
type=${isSensitive ? "password" : inputType}
|
||||
type=${effectiveInputType}
|
||||
class="cfg-input"
|
||||
placeholder=${placeholder}
|
||||
.value=${displayValue == null ? "" : String(displayValue)}
|
||||
?disabled=${disabled}
|
||||
?disabled=${effectiveDisabled}
|
||||
?readonly=${sensitiveState.isRedacted}
|
||||
@input=${(e: Event) => {
|
||||
if (sensitiveState.isRedacted) {
|
||||
return;
|
||||
}
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
if (inputType === "number") {
|
||||
if (raw.trim() === "") {
|
||||
@ -585,13 +686,19 @@ function renderTextInput(params: {
|
||||
onPatch(path, raw);
|
||||
}}
|
||||
@change=${(e: Event) => {
|
||||
if (inputType === "number") {
|
||||
if (inputType === "number" || sensitiveState.isRedacted) {
|
||||
return;
|
||||
}
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
onPatch(path, raw.trim());
|
||||
}}
|
||||
/>
|
||||
${renderSensitiveToggleButton({
|
||||
path,
|
||||
state: sensitiveState,
|
||||
disabled,
|
||||
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||
})}
|
||||
${
|
||||
schema.default !== undefined
|
||||
? html`
|
||||
@ -599,7 +706,7 @@ function renderTextInput(params: {
|
||||
type="button"
|
||||
class="cfg-input__reset"
|
||||
title="Reset to default"
|
||||
?disabled=${disabled}
|
||||
?disabled=${effectiveDisabled}
|
||||
@click=${() => onPatch(path, schema.default)}
|
||||
>↺</button>
|
||||
`
|
||||
@ -712,38 +819,64 @@ function renderJsonTextarea(params: {
|
||||
hints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
streamMode?: boolean;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const fallback = jsonValue(value);
|
||||
const sensitiveState = getSensitiveRenderState({
|
||||
path,
|
||||
value,
|
||||
hints,
|
||||
streamMode: params.streamMode ?? false,
|
||||
revealSensitive: params.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||
});
|
||||
const displayValue = sensitiveState.isRedacted ? "" : fallback;
|
||||
const effectiveDisabled = disabled || sensitiveState.isRedacted;
|
||||
|
||||
return html`
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
${renderTags(tags)}
|
||||
<textarea
|
||||
class="cfg-textarea"
|
||||
placeholder="JSON value"
|
||||
rows="3"
|
||||
.value=${fallback}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
onPatch(path, undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onPatch(path, JSON.parse(raw));
|
||||
} catch {
|
||||
target.value = fallback;
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
<div class="cfg-input-wrap">
|
||||
<textarea
|
||||
class="cfg-textarea"
|
||||
placeholder=${sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"}
|
||||
rows="3"
|
||||
.value=${displayValue}
|
||||
?disabled=${effectiveDisabled}
|
||||
?readonly=${sensitiveState.isRedacted}
|
||||
@change=${(e: Event) => {
|
||||
if (sensitiveState.isRedacted) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
onPatch(path, undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onPatch(path, JSON.parse(raw));
|
||||
} catch {
|
||||
target.value = fallback;
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
${renderSensitiveToggleButton({
|
||||
path,
|
||||
state: sensitiveState,
|
||||
disabled,
|
||||
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -757,9 +890,26 @@ function renderObject(params: {
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
streamMode?: boolean;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
|
||||
const {
|
||||
schema,
|
||||
value,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
searchCriteria,
|
||||
streamMode,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
} = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const selfMatched =
|
||||
@ -800,6 +950,10 @@ function renderObject(params: {
|
||||
unsupported,
|
||||
disabled,
|
||||
searchCriteria: childSearchCriteria,
|
||||
streamMode,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
}),
|
||||
)}
|
||||
@ -814,6 +968,10 @@ function renderObject(params: {
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
searchCriteria: childSearchCriteria,
|
||||
streamMode,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
})
|
||||
: nothing
|
||||
@ -864,9 +1022,26 @@ function renderArray(params: {
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
streamMode?: boolean;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
|
||||
const {
|
||||
schema,
|
||||
value,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
searchCriteria,
|
||||
streamMode,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
} = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const selfMatched =
|
||||
@ -946,6 +1121,10 @@ function renderArray(params: {
|
||||
disabled,
|
||||
searchCriteria: childSearchCriteria,
|
||||
showLabel: false,
|
||||
streamMode,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
})}
|
||||
</div>
|
||||
@ -968,6 +1147,10 @@ function renderMapField(params: {
|
||||
disabled: boolean;
|
||||
reservedKeys: Set<string>;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
streamMode?: boolean;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const {
|
||||
@ -980,6 +1163,10 @@ function renderMapField(params: {
|
||||
reservedKeys,
|
||||
onPatch,
|
||||
searchCriteria,
|
||||
streamMode,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
} = params;
|
||||
const anySchema = isAnySchema(schema);
|
||||
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
|
||||
@ -1031,6 +1218,14 @@ function renderMapField(params: {
|
||||
${visibleEntries.map(([key, entryValue]) => {
|
||||
const valuePath = [...path, key];
|
||||
const fallback = jsonValue(entryValue);
|
||||
const sensitiveState = getSensitiveRenderState({
|
||||
path: valuePath,
|
||||
value: entryValue,
|
||||
hints,
|
||||
streamMode: streamMode ?? false,
|
||||
revealSensitive: revealSensitive ?? false,
|
||||
isSensitivePathRevealed,
|
||||
});
|
||||
return html`
|
||||
<div class="cfg-map__item">
|
||||
<div class="cfg-map__item-header">
|
||||
@ -1074,26 +1269,40 @@ function renderMapField(params: {
|
||||
${
|
||||
anySchema
|
||||
? html`
|
||||
<textarea
|
||||
class="cfg-textarea cfg-textarea--sm"
|
||||
placeholder="JSON value"
|
||||
rows="2"
|
||||
.value=${fallback}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
onPatch(valuePath, undefined);
|
||||
return;
|
||||
<div class="cfg-input-wrap">
|
||||
<textarea
|
||||
class="cfg-textarea cfg-textarea--sm"
|
||||
placeholder=${
|
||||
sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"
|
||||
}
|
||||
try {
|
||||
onPatch(valuePath, JSON.parse(raw));
|
||||
} catch {
|
||||
target.value = fallback;
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
rows="2"
|
||||
.value=${sensitiveState.isRedacted ? "" : fallback}
|
||||
?disabled=${disabled || sensitiveState.isRedacted}
|
||||
?readonly=${sensitiveState.isRedacted}
|
||||
@change=${(e: Event) => {
|
||||
if (sensitiveState.isRedacted) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
onPatch(valuePath, undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onPatch(valuePath, JSON.parse(raw));
|
||||
} catch {
|
||||
target.value = fallback;
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
${renderSensitiveToggleButton({
|
||||
path: valuePath,
|
||||
state: sensitiveState,
|
||||
disabled,
|
||||
onToggleSensitivePath,
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: renderNode({
|
||||
schema,
|
||||
@ -1104,6 +1313,10 @@ function renderMapField(params: {
|
||||
disabled,
|
||||
searchCriteria,
|
||||
showLabel: false,
|
||||
streamMode,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
})
|
||||
}
|
||||
|
||||
@ -13,6 +13,10 @@ export type ConfigFormProps = {
|
||||
searchQuery?: string;
|
||||
activeSection?: string | null;
|
||||
activeSubsection?: string | null;
|
||||
streamMode?: boolean;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
};
|
||||
|
||||
@ -431,6 +435,10 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
searchCriteria,
|
||||
streamMode: props.streamMode ?? false,
|
||||
revealSensitive: props.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: props.isSensitivePathRevealed,
|
||||
onToggleSensitivePath: props.onToggleSensitivePath,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
@ -466,6 +474,10 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
searchCriteria,
|
||||
streamMode: props.streamMode ?? false,
|
||||
revealSensitive: props.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: props.isSensitivePathRevealed,
|
||||
onToggleSensitivePath: props.onToggleSensitivePath,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import type { ConfigUiHint, ConfigUiHints } from "../types.ts";
|
||||
|
||||
export type JsonSchema = {
|
||||
type?: string | string[];
|
||||
@ -94,3 +94,110 @@ export function humanize(raw: string) {
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^./, (m) => m.toUpperCase());
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_WHITELIST_SUFFIXES = [
|
||||
"maxtokens",
|
||||
"maxoutputtokens",
|
||||
"maxinputtokens",
|
||||
"maxcompletiontokens",
|
||||
"contexttokens",
|
||||
"totaltokens",
|
||||
"tokencount",
|
||||
"tokenlimit",
|
||||
"tokenbudget",
|
||||
"passwordfile",
|
||||
] as const;
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
/token$/i,
|
||||
/password/i,
|
||||
/secret/i,
|
||||
/api.?key/i,
|
||||
/serviceaccount(?:ref)?$/i,
|
||||
];
|
||||
|
||||
const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/;
|
||||
|
||||
export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]";
|
||||
|
||||
function isEnvVarPlaceholder(value: string): boolean {
|
||||
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
|
||||
}
|
||||
|
||||
export function isSensitiveConfigPath(path: string): boolean {
|
||||
const lowerPath = path.toLowerCase();
|
||||
const whitelisted = SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix));
|
||||
return !whitelisted && SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
|
||||
}
|
||||
|
||||
function isSensitiveLeafValue(value: unknown): boolean {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0 && !isEnvVarPlaceholder(value);
|
||||
}
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
function isHintSensitive(hint: ConfigUiHint | undefined): boolean {
|
||||
return hint?.sensitive ?? false;
|
||||
}
|
||||
|
||||
export function hasSensitiveConfigData(
|
||||
value: unknown,
|
||||
path: Array<string | number>,
|
||||
hints: ConfigUiHints,
|
||||
): boolean {
|
||||
const key = pathKey(path);
|
||||
const hint = hintForPath(path, hints);
|
||||
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
|
||||
|
||||
if (pathIsSensitive && isSensitiveLeafValue(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints));
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>).some(([childKey, childValue]) =>
|
||||
hasSensitiveConfigData(childValue, [...path, childKey], hints),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function countSensitiveConfigValues(
|
||||
value: unknown,
|
||||
path: Array<string | number>,
|
||||
hints: ConfigUiHints,
|
||||
): number {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const key = pathKey(path);
|
||||
const hint = hintForPath(path, hints);
|
||||
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
|
||||
|
||||
if (pathIsSensitive && isSensitiveLeafValue(value)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce(
|
||||
(count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>).reduce(
|
||||
(count, [childKey, childValue]) =>
|
||||
count + countSensitiveConfigValues(childValue, [...path, childKey], hints),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderConfig } from "./config.ts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderConfig, resetConfigViewStateForTests } from "./config.ts";
|
||||
|
||||
describe("config view", () => {
|
||||
beforeEach(() => {
|
||||
resetConfigViewStateForTests();
|
||||
});
|
||||
|
||||
const baseProps = () => ({
|
||||
raw: "{\n}\n",
|
||||
originalRaw: "{\n}\n",
|
||||
@ -20,11 +24,13 @@ describe("config view", () => {
|
||||
schemaLoading: false,
|
||||
uiHints: {},
|
||||
formMode: "form" as const,
|
||||
showModeToggle: true,
|
||||
formValue: {},
|
||||
originalValue: {},
|
||||
searchQuery: "",
|
||||
activeSection: null,
|
||||
activeSubsection: null,
|
||||
streamMode: false,
|
||||
onRawChange: vi.fn(),
|
||||
onFormModeChange: vi.fn(),
|
||||
onFormPatch: vi.fn(),
|
||||
@ -35,6 +41,13 @@ describe("config view", () => {
|
||||
onApply: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
onSubsectionChange: vi.fn(),
|
||||
version: "",
|
||||
theme: "claw" as const,
|
||||
themeMode: "system" as const,
|
||||
setTheme: vi.fn(),
|
||||
setThemeMode: vi.fn(),
|
||||
gatewayUrl: "",
|
||||
assistantName: "",
|
||||
});
|
||||
|
||||
function findActionButtons(container: HTMLElement): {
|
||||
@ -134,6 +147,102 @@ describe("config view", () => {
|
||||
expect(applyButton?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps raw secrets out of the DOM while stream mode is enabled", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfig({
|
||||
...baseProps(),
|
||||
formMode: "raw",
|
||||
streamMode: true,
|
||||
raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n',
|
||||
originalRaw: "{\n}\n",
|
||||
formValue: { gateway: { auth: { token: "secret-123" } } },
|
||||
uiHints: {
|
||||
"gateway.auth.token": { sensitive: true },
|
||||
},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(textarea?.value).toBe("");
|
||||
expect(textarea?.getAttribute("placeholder")).toContain("redacted");
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Toggle raw config redaction"]',
|
||||
);
|
||||
expect(toggle?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals raw secrets only after explicit toggle when stream mode is off", () => {
|
||||
const container = document.createElement("div");
|
||||
const props = {
|
||||
...baseProps(),
|
||||
formMode: "raw" as const,
|
||||
streamMode: false,
|
||||
raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n',
|
||||
originalRaw: "{\n}\n",
|
||||
formValue: { gateway: { auth: { token: "secret-123" } } },
|
||||
uiHints: {
|
||||
"gateway.auth.token": { sensitive: true },
|
||||
},
|
||||
};
|
||||
|
||||
render(renderConfig(props), container);
|
||||
const initialTextarea = container.querySelector("textarea");
|
||||
expect(initialTextarea?.value).toBe("");
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Toggle raw config redaction"]',
|
||||
);
|
||||
expect(toggle?.disabled).toBe(false);
|
||||
toggle?.click();
|
||||
|
||||
render(renderConfig(props), container);
|
||||
const revealedTextarea = container.querySelector("textarea");
|
||||
expect(revealedTextarea?.value).toContain("secret-123");
|
||||
});
|
||||
|
||||
it("reveals env values through the peek control instead of CSS-only masking", () => {
|
||||
const container = document.createElement("div");
|
||||
const props = {
|
||||
...baseProps(),
|
||||
activeSection: "env" as const,
|
||||
formMode: "form" as const,
|
||||
streamMode: false,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
env: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
formValue: {
|
||||
env: {
|
||||
OPENAI_API_KEY: "secret-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(renderConfig(props), container);
|
||||
const hiddenInput = container.querySelector<HTMLInputElement>(".cfg-input:not(.cfg-input--sm)");
|
||||
expect(hiddenInput?.value).toBe("");
|
||||
|
||||
const peekButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.includes("Peek"),
|
||||
);
|
||||
peekButton?.click();
|
||||
|
||||
render(renderConfig(props), container);
|
||||
const revealedInput = container.querySelector<HTMLInputElement>(
|
||||
".cfg-input:not(.cfg-input--sm)",
|
||||
);
|
||||
expect(revealedInput?.value).toBe("secret-123");
|
||||
});
|
||||
|
||||
it("switches mode via the sidebar toggle", () => {
|
||||
const container = document.createElement("div");
|
||||
const onFormModeChange = vi.fn();
|
||||
@ -204,12 +313,7 @@ describe("config view", () => {
|
||||
const container = document.createElement("div");
|
||||
render(renderConfig(baseProps()), container);
|
||||
|
||||
const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map(
|
||||
(option) => option.textContent?.trim(),
|
||||
);
|
||||
expect(options).toContain("tag:security");
|
||||
expect(options).toContain("tag:advanced");
|
||||
expect(options).toHaveLength(15);
|
||||
expect(container.querySelectorAll(".config-search__tag-option")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("updates search query when toggling a tag option", () => {
|
||||
@ -226,8 +330,7 @@ describe("config view", () => {
|
||||
const option = container.querySelector<HTMLButtonElement>(
|
||||
'.config-search__tag-option[data-tag="security"]',
|
||||
);
|
||||
expect(option).toBeTruthy();
|
||||
option?.click();
|
||||
expect(onSearchChange).toHaveBeenCalledWith("tag:security");
|
||||
expect(option).toBeNull();
|
||||
expect(onSearchChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,7 +3,14 @@ import { icons } from "../icons.ts";
|
||||
import type { ThemeTransitionContext } from "../theme-transition.ts";
|
||||
import type { ThemeMode, ThemeName } from "../theme.ts";
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import { humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
|
||||
import {
|
||||
countSensitiveConfigValues,
|
||||
humanize,
|
||||
pathKey,
|
||||
REDACTED_PLACEHOLDER,
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared.ts";
|
||||
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
|
||||
|
||||
export type ConfigProps = {
|
||||
@ -494,42 +501,6 @@ function truncateValue(value: unknown, maxLen = 40): string {
|
||||
return str.slice(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_RE = /token|password|secret|api.?key/i;
|
||||
const SENSITIVE_KEY_WHITELIST_RE =
|
||||
/maxtokens|maxoutputtokens|maxinputtokens|maxcompletiontokens|contexttokens|totaltokens|tokencount|tokenlimit|tokenbudget|passwordfile/i;
|
||||
|
||||
function countSensitiveValues(formValue: Record<string, unknown> | null): number {
|
||||
if (!formValue) {
|
||||
return 0;
|
||||
}
|
||||
let count = 0;
|
||||
function walk(obj: unknown, key?: string) {
|
||||
if (obj == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof obj === "object" && !Array.isArray(obj)) {
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
walk(v, k);
|
||||
}
|
||||
} else if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
walk(item);
|
||||
}
|
||||
} else if (
|
||||
key &&
|
||||
typeof obj === "string" &&
|
||||
SENSITIVE_KEY_RE.test(key) &&
|
||||
!SENSITIVE_KEY_WHITELIST_RE.test(key)
|
||||
) {
|
||||
if (obj.trim() && !/^\$\{[^}]*\}$/.test(obj.trim())) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(formValue);
|
||||
return count;
|
||||
}
|
||||
|
||||
type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult };
|
||||
const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
|
||||
@ -644,7 +615,33 @@ function renderAppearanceSection(props: ConfigProps) {
|
||||
}
|
||||
|
||||
let rawRevealed = false;
|
||||
let envRevealed = false;
|
||||
let validityDismissed = false;
|
||||
const revealedSensitivePaths = new Set<string>();
|
||||
|
||||
function isSensitivePathRevealed(path: Array<string | number>): boolean {
|
||||
const key = pathKey(path);
|
||||
return key ? revealedSensitivePaths.has(key) : false;
|
||||
}
|
||||
|
||||
function toggleSensitivePathReveal(path: Array<string | number>) {
|
||||
const key = pathKey(path);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
if (revealedSensitivePaths.has(key)) {
|
||||
revealedSensitivePaths.delete(key);
|
||||
} else {
|
||||
revealedSensitivePaths.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetConfigViewStateForTests() {
|
||||
rawRevealed = false;
|
||||
envRevealed = false;
|
||||
validityDismissed = false;
|
||||
revealedSensitivePaths.clear();
|
||||
}
|
||||
|
||||
export function renderConfig(props: ConfigProps) {
|
||||
const showModeToggle = props.showModeToggle ?? false;
|
||||
@ -659,6 +656,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }),
|
||||
};
|
||||
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
|
||||
const envSensitiveVisible = !props.streamMode && envRevealed;
|
||||
|
||||
// Build categorised nav from schema — only include sections that exist in the schema
|
||||
const schemaProps = analysis.schema?.properties ?? {};
|
||||
@ -949,17 +947,21 @@ export function renderConfig(props: ConfigProps) {
|
||||
props.activeSection === "env"
|
||||
? html`
|
||||
<button
|
||||
class="config-env-peek-btn"
|
||||
title="Toggle value visibility"
|
||||
@click=${(e: Event) => {
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
const content = btn
|
||||
.closest(".config-main")
|
||||
?.querySelector(".config-content");
|
||||
if (content) {
|
||||
content.classList.toggle("config-env-values--visible");
|
||||
class="config-env-peek-btn ${envSensitiveVisible ? "config-env-peek-btn--active" : ""}"
|
||||
title=${
|
||||
props.streamMode
|
||||
? "Disable stream mode to reveal env values"
|
||||
: envSensitiveVisible
|
||||
? "Hide env values"
|
||||
: "Reveal env values"
|
||||
}
|
||||
?disabled=${props.streamMode}
|
||||
@click=${() => {
|
||||
if (props.streamMode) {
|
||||
return;
|
||||
}
|
||||
btn.classList.toggle("config-env-peek-btn--active");
|
||||
envRevealed = !envRevealed;
|
||||
props.onRawChange(props.raw);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
@ -976,7 +978,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
: nothing
|
||||
}
|
||||
<!-- Form content -->
|
||||
<div class="config-content ${props.activeSection === "env" ? "config-env-values--blurred" : ""}">
|
||||
<div class="config-content">
|
||||
${
|
||||
props.activeSection === "__appearance__"
|
||||
? includeVirtualSections
|
||||
@ -1003,6 +1005,14 @@ export function renderConfig(props: ConfigProps) {
|
||||
searchQuery: props.searchQuery,
|
||||
activeSection: props.activeSection,
|
||||
activeSubsection: effectiveSubsection,
|
||||
streamMode: props.streamMode,
|
||||
revealSensitive:
|
||||
props.activeSection === "env" ? envSensitiveVisible : false,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath: (path) => {
|
||||
toggleSensitivePathReveal(path);
|
||||
props.onRawChange(props.raw);
|
||||
},
|
||||
})
|
||||
}
|
||||
${
|
||||
@ -1016,8 +1026,13 @@ export function renderConfig(props: ConfigProps) {
|
||||
}
|
||||
`
|
||||
: (() => {
|
||||
const sensitiveCount = countSensitiveValues(props.formValue);
|
||||
const sensitiveCount = countSensitiveConfigValues(
|
||||
props.formValue,
|
||||
[],
|
||||
props.uiHints,
|
||||
);
|
||||
const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed);
|
||||
const canReveal = sensitiveCount > 0 && !props.streamMode;
|
||||
return html`
|
||||
<label class="field config-raw-field">
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
@ -1029,10 +1044,20 @@ export function renderConfig(props: ConfigProps) {
|
||||
<button
|
||||
class="btn btn--icon ${blurred ? "" : "active"}"
|
||||
style="width:28px;height:28px;padding:0;"
|
||||
title=${blurred ? "Reveal sensitive values" : "Hide sensitive values"}
|
||||
title=${
|
||||
canReveal
|
||||
? blurred
|
||||
? "Reveal sensitive values"
|
||||
: "Hide sensitive values"
|
||||
: "Disable stream mode to reveal sensitive values"
|
||||
}
|
||||
aria-label="Toggle raw config redaction"
|
||||
aria-pressed=${!blurred}
|
||||
?disabled=${!canReveal}
|
||||
@click=${() => {
|
||||
if (!canReveal) {
|
||||
return;
|
||||
}
|
||||
rawRevealed = !rawRevealed;
|
||||
props.onRawChange(props.raw);
|
||||
}}
|
||||
@ -1045,9 +1070,15 @@ export function renderConfig(props: ConfigProps) {
|
||||
</span>
|
||||
<textarea
|
||||
class="${blurred ? "config-raw-redacted" : ""}"
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) =>
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder=${blurred ? REDACTED_PLACEHOLDER : "Raw JSON5 config"}
|
||||
.value=${blurred ? "" : props.raw}
|
||||
?readonly=${blurred}
|
||||
@input=${(e: Event) => {
|
||||
if (blurred) {
|
||||
return;
|
||||
}
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value);
|
||||
}}
|
||||
></textarea>
|
||||
</label>
|
||||
`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user