Files
cobble/src/pages/admin/bots/[bot_id]/(partials)/secret-request-card.tsx
T
tbenandClaude Opus 5.5 69988e08fc Move secrets between users and computers without the model seeing them
A new personal env scope (paradigm user: one user's secret for one bot)
beats every other scope, reaches only that user's runs of that bot, and
is redacted like any secret. Bots take secrets in with request_secret,
which puts a [secret-request:ID] card in the chat: the user types the
value into a masked input there, never into a message, and the bot is
told only the key. Bots show secrets with capture_secret (a task's
computer tool that saves a file or a command's output straight to a
personal env, then redacts it from the rest of the run) and show_secret
(an env by key): both give a [secret:ID] chip whose Reveal and Copy fetch
the value on click, for its own user (personal) or a super admin
(anything else), logged by key. Values never go into messages, page
props, history, Copy chat or logs. Users manage their own secrets on
Settings > Secrets; the Environment page lists everyone's, remove only.
Deleting a bot or user removes its personal secrets, and SecretsNote
tells bots never to ask for or print secrets in chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:51:32 +01:00

136 lines
5.2 KiB
TypeScript

import { useContext, useState } from "react";
import Input from "@moduletrace/twui/form/Input";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import type { SecretRequestView } from "@/src/types";
import decideSecretRequest from "../(functions)/decide-secret-request";
import sendBotMessage from "../(functions)/send-bot-message";
import secretRequestMessage from "../(utils)/secret-request-message";
import MessageDetailsHeading from "./message-details-heading";
type Props = {
view: SecretRequestView;
onDecided: (view: SecretRequestView) => void;
onDone: () => void;
};
/**
* The card a user types a secret a bot asked for into. The value goes
* from here to the app only: it's saved encrypted as their personal secret
* for the bot, never put in a message, and the bot (and its model
* provider) only learns its key. Saving or declining tells the bot in chat.
*/
export default function SecretRequestCard({ view, onDecided, onDone }: Props) {
const { pageProps } = useContext(AppContext);
const [value, setValue] = useState("");
const [busy, setBusy] = useState<"save" | "decline">();
const [error, setError] = useState<string>();
const open = view.status == "pending";
const decide = async (decision: "save" | "decline") => {
if (decision == "save" && !value) {
setError(`Enter the value first.`);
return;
}
const question =
decision == "save"
? `Save ${view.key} for ${view.bot_name}? Its runs for you get it on their computers; it's never shown to the bot.`
: `Decline? Nothing will be saved.`;
if (!window.confirm(question)) return;
setBusy(decision);
setError(undefined);
const res = await decideSecretRequest({
request_id: view.id,
value: decision == "save" ? value : undefined,
}).catch(() => null);
if (!res?.success || !res.singleRes) {
setBusy(undefined);
setError(res?.msg || `Couldn't ${decision} it.`);
return;
}
setValue("");
onDecided(res.singleRes);
await sendBotMessage({
bot_id: Number(pageProps?.bot?.id) || 0,
workspace_id: pageProps?.workspace?.id || null,
text: secretRequestMessage({ view: res.singleRes }),
media_ids: [],
}).catch(() => null);
setBusy(undefined);
onDone();
};
return (
<Stack className="w-full gap-0! items-stretch">
<MessageDetailsHeading>What it is</MessageDetailsHeading>
<Span className="text-[13px] leading-5 text-ink break-words">{view.description}</Span>
<Span className="mt-1 text-[12px] leading-4 text-ink-secondary">{`Saved as ${view.key}, for your runs of ${view.bot_name} only.`}</Span>
<MessageDetailsHeading>Why (written by the bot)</MessageDetailsHeading>
<Span className="whitespace-pre-wrap text-[13px] leading-5 text-ink break-words">{view.reason}</Span>
{open ? (
<Stack className="mt-5 w-full gap-2! items-stretch">
<Input
id={`secret-request-${view.id}`}
title={view.key}
label={view.key}
showLabel
type="password"
autoComplete="off"
placeholder="Paste or type it here, not in the chat"
info="Encrypted before it's stored. The bot and its model never see it."
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Stack>
) : (
<Span className="mt-4 text-[13px] leading-5 text-ink">
{view.status == "saved" ? `Saved. You can replace or remove it in Settings, under Secrets.` : `Declined. Nothing was saved.`}
</Span>
)}
{error ? <Span className="mt-2 text-[13px] leading-5 text-ink">{error}</Span> : null}
{open ? (
<Row className="mt-5 w-full gap-2! justify-end">
<Button
type="button"
variant="outlined"
color="gray"
size="small"
title="Decline: nothing is saved"
loading={busy == "decline"}
disabled={Boolean(busy)}
onClick={() => decide("decline")}
>
Decline
</Button>
<Button
type="button"
size="small"
title={`Save ${view.key}`}
loading={busy == "save"}
disabled={Boolean(busy)}
onClick={() => decide("save")}
>
Save
</Button>
</Row>
) : null}
</Stack>
);
}