Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 321 additions & 0 deletions packages/admin/src/components/ToolTester.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { testTool, type ToolTestResult } from "@/lib/api";

interface ToolTesterProps {
siteId: string;
tool: {
name: string;
description: string;
endpoint: { url: string; method: string; headers?: Record<string, string> };
parameters: { type: "object"; properties: Record<string, unknown>; required?: string[] };
};
onClose: () => void;
}

const SAMPLE_ARGS = '{\n "example": "value"\n}';

/**
* Modal that lets an admin run a single custom tool and see the response.
*
* Accessibility:
* - role="dialog" + aria-modal
* - Esc to close, focus traps inside the modal
* - First interactive element gets focus on mount
* - aria-live="polite" for the response region so screen readers
* announce the result.
*/
export function ToolTester({ siteId, tool, onClose }: ToolTesterProps) {
const [argsText, setArgsText] = useState<string>(SAMPLE_ARGS);
const [argsError, setArgsError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [result, setResult] = useState<ToolTestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<"result" | "curl" | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const firstButtonRef = useRef<HTMLButtonElement | null>(null);

// Build a sensible initial arg template from the tool's parameter schema.
useEffect(() => {
const props = tool.parameters.properties || {};
const initial: Record<string, unknown> = {};
let hasAny = false;
for (const [name, def] of Object.entries(props)) {
hasAny = true;
const t = (def as { type?: string }).type;
if (t === "string") initial[name] = "";
else if (t === "number" || t === "integer") initial[name] = 0;
else if (t === "boolean") initial[name] = false;
else initial[name] = null;
}
if (hasAny) setArgsText(JSON.stringify(initial, null, 2));
}, [tool]);

// Focus management: focus first button on mount, trap focus inside modal.
useEffect(() => {
firstButtonRef.current?.focus();
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);

const parsedArgs = useMemo<Record<string, unknown> | null>(() => {
if (!argsText.trim()) return {};
try {
const parsed = JSON.parse(argsText);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}, [argsText]);

const handleArgsChange = (text: string) => {
setArgsText(text);
if (!text.trim()) {
setArgsError(null);
return;
}
try {
const parsed = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
setArgsError("Arguments must be a JSON object");
} else {
setArgsError(null);
}
} catch (err) {
setArgsError(err instanceof Error ? err.message : "Invalid JSON");
}
};

const run = useCallback(async () => {
if (!parsedArgs) {
setArgsError("Fix the arguments JSON before running");
return;
}
setRunning(true);
setError(null);
setResult(null);
try {
const r = await testTool(siteId, tool.name, parsedArgs);
setResult(r);
} catch (err) {
setError(err instanceof Error ? err.message : "Tool test failed");
} finally {
setRunning(false);
}
}, [parsedArgs, siteId, tool.name]);

const curlExample = useMemo(() => {
const body = JSON.stringify({
tool: tool.name,
arguments: parsedArgs ?? {},
});
// shell-escape a single-quoted string for the POSIX shell:
// 'foo' → '\'' replacement handles embedded single quotes safely.
const shq = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
const method = (tool.endpoint.method ?? "POST").toUpperCase();
const headerLines: string[] = [` -H 'Content-Type: application/json'`];
for (const [k, v] of Object.entries(tool.endpoint.headers ?? {})) {
if (typeof v !== "string") continue;
headerLines.push(` -H ${shq(`${k}: ${v}`)}`);
}
Comment on lines +142 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C6 'redact|REDACT|\*\*\*' packages/server/src/routes/admin/sites.ts
rg -n -C4 'endpoint' packages/server/src/routes/admin/sites.ts

Repository: Chafficui/kody

Length of output: 2253


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- redaction helper definitions and usages ---'
rg -n -C8 'redactConfigSecrets|function fetchSite|fetchSite|headers' packages --glob '*.ts' --glob '*.tsx' | head -n 260
printf '%s\n' '--- candidate files ---'
fd -i 'redact|site|ToolTester' packages

Repository: Chafficui/kody

Length of output: 18694


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- redaction helper ---'
rg -n -C12 'redactConfigSecrets' packages/shared packages/server packages/admin --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- site configuration types and tests ---'
rg -n -C8 'headers|redact|REDACT|placeholder|secret' packages/shared packages/server/tests packages/admin --glob '*.ts' --glob '*.tsx' | head -n 320
printf '%s\n' '--- relevant component and form flow ---'
cat -n packages/admin/src/components/ToolTester.tsx | sed -n '120,160p'
cat -n packages/admin/src/pages/SiteEdit.tsx | sed -n '170,215p'

Repository: Chafficui/kody

Length of output: 38134


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- complete redaction implementation ---'
cat -n packages/shared/src/validators/site-config.ts | sed -n '335,385p'
printf '%s\n' '--- endpoint schema and secret-related fields ---'
rg -n -C12 'endpointSchema|headers:|secret:|auth:' packages/shared/src/validators/site-config.ts
printf '%s\n' '--- admin response redaction tests ---'
rg -n -C12 'redactConfigSecrets|headers|secret|auth.value' packages/server/tests/integration/admin/sites.test.ts packages/shared/tests/site-config.test.ts

Repository: Chafficui/kody

Length of output: 6986


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re

helper = Path("packages/shared/src/validators/site-config.ts").read_text()
routes = Path("packages/server/src/routes/admin/sites.ts").read_text()
component = Path("packages/admin/src/components/ToolTester.tsx").read_text()

match = re.search(
    r"export function redactConfigSecrets\(config: SiteConfig\): SiteConfig \{.*?\n\}",
    helper,
    re.S,
)
assert match, "redactConfigSecrets was not found"
body = match.group(0)

assert "headers:" not in body, "The helper assigns a redacted headers value"
assert "secret: tool.endpoint.secret ? \"\"" in body
assert "value: \"\"" in body
assert routes.count("redactConfigSecrets(") >= 4
assert "Object.entries(tool.endpoint.headers ?? {})" in component
assert "navigator.clipboard.writeText(text)" in component

print("redactConfigSecrets clears endpoint.secret and endpoint.auth.value only")
print("admin site responses pass through redactConfigSecrets")
print("ToolTester renders endpoint.headers and can copy the generated command")
PY

Repository: Chafficui/kody

Length of output: 349


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re

helper = Path("packages/shared/src/validators/site-config.ts").read_text()
routes = Path("packages/server/src/routes/admin/sites.ts").read_text()
component = Path("packages/admin/src/components/ToolTester.tsx").read_text()

match = re.search(
    r"export function redactConfigSecrets\(config: SiteConfig\): SiteConfig \{.*?\n\}",
    helper,
    re.S,
)
assert match, "redactConfigSecrets was not found"
body = match.group(0)

assert "headers:" not in body, "The helper assigns a redacted headers value"
assert 'secret: tool.endpoint.secret ? ""' in body
assert 'value: ""' in body
assert routes.count("redactConfigSecrets(") >= 4
assert "Object.entries(tool.endpoint.headers ?? {})" in component
assert "navigator.clipboard.writeText(text)" in component

print("redactConfigSecrets clears endpoint.secret and endpoint.auth.value only")
print("admin site responses pass through redactConfigSecrets")
print("ToolTester renders endpoint.headers and can copy the generated command")
PY

Repository: Chafficui/kody

Length of output: 349


State that generated curl commands include configured header values.

redactConfigSecrets clears only endpoint.secret and endpoint.auth.value. endpoint.headers remains unchanged. ToolTester includes these values in the generated command and clipboard content. Add an explicit warning before displaying or copying the command.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/admin/src/components/ToolTester.tsx` around lines 142 - 145, The
generated curl command in ToolTester includes unredacted endpoint.headers
values. Before displaying or copying the command, add an explicit warning that
configured header values are included, covering both the rendered command and
clipboard content while preserving the existing command generation.

return [
`curl -X ${method} ${shq(tool.endpoint.url)} \\`,
...headerLines,
` -d ${shq(body)}`,
].join("\n");
}, [tool, parsedArgs]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const copy = async (text: string, kind: "result" | "curl") => {
try {
await navigator.clipboard.writeText(text);
setCopied(kind);
setTimeout(() => setCopied(null), 1500);
} catch {
// Clipboard might be blocked; ignore silently.
}
};

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="tool-tester-title"
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 id="tool-tester-title" className="text-lg font-semibold">
Test tool: {tool.name}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{tool.description}</p>
<p className="mt-1 text-xs text-muted-foreground">
{tool.endpoint.method} {tool.endpoint.url}
</p>
</div>
<button
ref={firstButtonRef}
type="button"
onClick={onClose}
aria-label="Close tool tester"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span aria-hidden="true">×</span>
</button>
</header>

<div className="flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div>
<label htmlFor="tool-args" className="text-sm font-medium">
Arguments (JSON)
</label>
<textarea
id="tool-args"
value={argsText}
onChange={(e) => handleArgsChange(e.target.value)}
rows={8}
aria-invalid={argsError ? "true" : "false"}
aria-describedby={argsError ? "tool-args-error" : "tool-args-help"}
className={`mt-1 w-full rounded-lg border bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 ${
argsError
? "border-red-400 focus:ring-red-200"
: "border-border focus:ring-primary/25"
}`}
spellCheck={false}
/>
{argsError ? (
<p id="tool-args-error" className="mt-1 text-xs text-red-600">
{argsError}
</p>
) : (
<p id="tool-args-help" className="mt-1 text-xs text-muted-foreground">
Object body sent to the tool. Empty object is allowed.
</p>
)}
</div>

<div>
<button
type="button"
onClick={run}
disabled={running || argsError !== null}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary-dark disabled:cursor-not-allowed disabled:opacity-60"
>
{running ? "Running…" : "Run"}
</button>
</div>

<div aria-live="polite" className="space-y-2">
{error && (
<div
role="alert"
className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
>
{error}
</div>
)}
{result && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<span
className={`inline-flex h-2 w-2 rounded-full ${
result.ok ? "bg-green-500" : "bg-red-500"
}`}
aria-hidden="true"
/>
<span className="font-medium">
{result.ok ? "Success" : "Failed"}
</span>
{result.truncated && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800">
truncated to 10 KB
</span>
)}
</div>
<button
type="button"
onClick={() => copy(result.result, "result")}
className="rounded-md border border-border px-3 py-1 text-xs font-medium transition-colors hover:bg-muted"
>
{copied === "result" ? "Copied" : "Copy"}
</button>
</div>
<pre
className={`max-h-72 overflow-auto rounded-lg border px-3 py-2 font-mono text-xs ${
result.ok
? "border-border bg-muted/30"
: "border-red-200 bg-red-50 dark:bg-red-950/30"
}`}
>
{result.result}
</pre>
</div>
)}
</div>

<details className="rounded-lg border border-border bg-muted/20 px-3 py-2">
<summary className="cursor-pointer text-sm font-medium">
Copy as curl
</summary>
<div className="mt-2 space-y-2">
<pre className="max-h-48 overflow-auto rounded border border-border bg-background px-3 py-2 font-mono text-xs">
{curlExample}
</pre>
<button
type="button"
onClick={() => copy(curlExample, "curl")}
className="rounded-md border border-border px-3 py-1 text-xs font-medium transition-colors hover:bg-muted"
>
{copied === "curl" ? "Copied" : "Copy curl"}
</button>
<p className="text-xs text-muted-foreground">
Use this to call the tool from outside the admin.
</p>
</div>
</details>
</div>

<footer className="flex justify-end border-t border-border bg-muted/30 px-5 py-3">
<button
type="button"
onClick={onClose}
className="rounded-md border border-border bg-background px-4 py-1.5 text-sm font-medium transition-colors hover:bg-muted"
>
Close
</button>
</footer>
</div>
</div>
);
}
37 changes: 37 additions & 0 deletions packages/admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,43 @@ export async function fetchUsers(): Promise<unknown[]> {
return res.json();
}

// Tool test (admin only)
export interface ToolTestResult {
ok: boolean;
name: string;
result: string;
truncated: boolean;
displayText: string;
tool: {
name: string;
description: string;
endpoint: string;
method: string;
};
}

export async function testTool(
siteId: string,
toolName: string,
args: Record<string, unknown>,
): Promise<ToolTestResult> {
const res = await apiFetch(
`/api/admin/sites/${encodeURIComponent(siteId)}/tools/${encodeURIComponent(toolName)}/test`,
{
method: "POST",
body: JSON.stringify({ arguments: args }),
},
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const msg =
(body && typeof body === "object" && "error" in body && body.error?.message) ||
`Tool test failed (${res.status})`;
throw new Error(msg);
}
return body as ToolTestResult;
}

export async function createUser(email: string, password: string): Promise<unknown> {
const res = await apiFetch("/api/admin/users", {
method: "POST",
Expand Down
Loading
Loading