Skip to content
Source
Sign inSign up

secretRequest.ts

237 lines8.7 KB

1import {
2 SecretReference,
3 secretRequestPageUrl,
4 type OrchestrationV2TurnItem,
5 type SecretProviderId,
6 type SecretRequestAnswerInput,
7 type ServerConfig,
8 type ServerSettings,
9} from "@t3tools/contracts";
10import { builtInSecretProviderDefinitions } from "@t3tools/secret-provider-definitions/registry";
11import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";
13​
14export type SecretRequestItem = Extract<
15 OrchestrationV2TurnItem,
16 { readonly type: "secret_request" }
17>;
18​
19/** A `file` request lets the agent's commands read the value, so its card must not promise more. */
20export const secretRequestPrivacyNote = (item: Pick<SecretRequestItem, "delivery">) =>
21 item.delivery === "file"
22 ? "Kept out of chat, in a private file this agent's commands can read for 24 hours"
23 : "Stored securely, never shown to the agent";
24export const SECRET_REQUEST_DEFAULT_PLACEHOLDER = "Paste the secret";
25​
26// Backticks, <angle brackets> and markdown link targets (balanced parentheses allowed) keep their
27// exact text; a bare link drops trailing sentence punctuation.
28const REASON_LINK =
29 /`(https?:\/\/[^`\s]+)`|<(https?:\/\/[^>\s]+)>|\]\((https?:\/\/(?:[^()\s]|\([^()\s]*\))+)\)|(https?:\/\/[^\s<>"'`[\]]+)/giu;
30/** Drops trailing sentence punctuation; a `)` stays when the link opened a matching `(`. */
31function trimPunctuation(link: string): string {
32 let end = link.length;
33 while (end > 0 && ".,;:!?)".includes(link[end - 1]!)) {
34 const kept = link.slice(0, end);
35 if (link[end - 1] === ")" && kept.split("(").length > kept.split(")").length - 1) break;
36 end -= 1;
37 }
38 return link.slice(0, end);
40​
41/** Drops a closing `*` or `_` run only when the same run opened emphasis before the link and is still open. */
42function trimEmphasis(link: string, before: string) {
43 const run = /(\*{1,3}|_{1,3})$/u.exec(link)?.[1];
44 if (run === undefined) return link;
45 const escaped = run.replaceAll("*", "\\*");
46 // Nested emphasis (`**_url_**`) opens one run right after another.
47 const opener = [...before.matchAll(new RegExp(`(?:^|[\\s(*_])${escaped}(?=\\S|$)`, "gu"))].at(-1);
48 if (opener === undefined) return link;
49 const closed = new RegExp(`\\S${escaped}(?:[\\s.,;:!?)]|$)`, "u").test(
50 before.slice(opener.index + opener[0].length),
51 );
52 return closed ? link : trimPunctuation(link.slice(0, -run.length));
54​
55/** The one distinct http(s) link in `reason`, for agents that put it there instead of in `url`. */
56function reasonLink(reason: string): string | undefined {
57 const raw = Array.from(reason.matchAll(REASON_LINK), (match) => {
58 const [, ticked, angled, target, bare] = match;
59 if (bare === undefined) return ticked ?? angled ?? target ?? "";
60 const before = reason.slice(0, match.index);
61 let link = trimPunctuation(bare);
62 for (let next = trimEmphasis(link, before); next !== link; next = trimEmphasis(link, before)) {
63 link = next;
64 }
65 return link;
66 });
67 // Two spellings of one page (`https://a.dev` and `https://a.dev/`) count once.
68 const links = new Set(raw.map((link) => secretRequestPageUrl(link)?.href ?? link));
69 return links.size === 1 ? [...links][0] : undefined;
71​
72/** Only http(s), so an agent cannot make the card open another scheme. */
73export function secretRequestLink(item: Pick<SecretRequestItem, "url" | "reason">) {
74 const raw = item.url ?? reasonLink(item.reason);
75 const url = raw === undefined ? null : secretRequestPageUrl(raw);
76 return url === null ? null : { href: url.href, host: url.host };
78​
79export type SecretRequestDisplay =
80 | { readonly kind: "pending" }
81 | { readonly kind: "pending-elsewhere"; readonly label: string }
82 | {
83 readonly kind: "answered";
84 readonly outcome: "saved" | "declined" | "ended";
85 readonly label: string;
86 };
87​
88const SAVED_DISPLAY: SecretRequestDisplay = {
89 kind: "answered",
90 outcome: "saved",
91 label: "Saved securely and kept private",
92};
93const DECLINED_DISPLAY: SecretRequestDisplay = {
94 kind: "answered",
95 outcome: "declined",
96 label: "Declined",
97};
98const ENDED_DISPLAY: SecretRequestDisplay = {
99 kind: "answered",
100 outcome: "ended",
101 label: "Request ended",
102};
103const PENDING_DISPLAY: SecretRequestDisplay = { kind: "pending" };
104const PENDING_ELSEWHERE_DISPLAY: SecretRequestDisplay = {
105 kind: "pending-elsewhere",
106 label: "Waiting for an answer in the original thread",
107};
108​
109/**
110 * `visibility` is the projected row's: a request inherited from another
111 * thread (a fork) can only be answered where it was asked.
112 */
113export function secretRequestDisplay(
114 item: Pick<SecretRequestItem, "secretStatus">,
115 visibility: "local" | "inherited" | "synthetic",
116): SecretRequestDisplay {
117 switch (item.secretStatus) {
118 case "pending":
119 return visibility === "local" ? PENDING_DISPLAY : PENDING_ELSEWHERE_DISPLAY;
120 case "saved":
121 return SAVED_DISPLAY;
122 case "declined":
123 return DECLINED_DISPLAY;
124 case "cancelled":
125 return ENDED_DISPLAY;
126 }
128​
129/** A configured secret provider a pending card can read its answer from. */
130export interface SecretRequestSource {
131 readonly id: SecretProviderId;
132 readonly label: string;
133 readonly isReference: (reference: string) => boolean;
134 readonly referencePlaceholder: string;
135 readonly invalidReferenceMessage: string;
137​
138const NO_SOURCES: ReadonlyArray<SecretRequestSource> = [];
139const decodeConfigByKind = new Map(
140 builtInSecretProviderDefinitions.definitions.map((definition) => [
141 definition.kind,
142 Schema.decodeUnknownOption(definition.configSchema),
143 ]),
144);
145​
146/** Empty unless the server accepts reference answers; providers this build cannot read are left out. */
147export function secretRequestSources(
148 config: Pick<ServerConfig, "secretRequestReferences"> | null | undefined,
149 providers: ServerSettings["secretProviders"],
150): ReadonlyArray<SecretRequestSource> {
151 if (config?.secretRequestReferences !== true) return NO_SOURCES;
152 return configuredSecretSources(providers);
154​
155export function configuredSecretSources(
156 providers: ServerSettings["secretProviders"],
157): ReadonlyArray<SecretRequestSource> {
158 return (Object.keys(providers) as Array<SecretProviderId>).flatMap((id) => {
159 const provider = providers[id];
160 const definition =
161 provider === undefined ? undefined : builtInSecretProviderDefinitions.find(provider.kind);
162 if (
163 provider === undefined ||
164 definition === undefined ||
165 decodeConfigByKind.get(definition.kind)?.(provider.config)._tag !== "Some"
166 ) {
167 return [];
168 }
169 return [
170 {
171 id,
172 label: provider.label,
173 isReference: definition.isReference,
174 referencePlaceholder: definition.referencePlaceholder,
175 invalidReferenceMessage: definition.invalidReferenceMessage,
176 },
177 ];
178 });
180​
181const decodeSecretReference = Schema.decodeUnknownOption(SecretReference);
182​
183/** The reference `text` names, or undefined when it is not `source`'s syntax. */
184export function secretRequestReference(
185 source: SecretRequestSource,
186 text: string,
187): SecretReference | undefined {
188 const decoded = decodeSecretReference({ provider: source.id, reference: text.trim() });
189 return Option.isSome(decoded) && source.isReference(decoded.value.reference)
190 ? decoded.value
191 : undefined;
193​
194export type SecretRequestReply =
195 | { readonly type: "save"; readonly secret: string }
196 | { readonly type: "reference"; readonly source: SecretRequestSource; readonly reference: string }
197 | { readonly type: "decline" };
198​
199export function secretRequestAnswerInput(
200 item: Pick<SecretRequestItem, "id" | "threadId">,
201 answer: SecretRequestReply,
202): SecretRequestAnswerInput | null {
203 if (answer.type === "decline") {
204 return { threadId: item.threadId, turnItemId: item.id, answer: { type: "decline" } };
205 }
206 if (answer.type === "reference") {
207 const reference = secretRequestReference(answer.source, answer.reference);
208 return reference === undefined
209 ? null
210 : { threadId: item.threadId, turnItemId: item.id, answer: { type: "reference", reference } };
211 }
212 const secret = answer.secret.trim();
213 if (secret.length === 0) return null;
214 return { threadId: item.threadId, turnItemId: item.id, answer: { type: "save", secret } };
216​
217const USER_FACING_FAILURE_TAGS = new Set(["SecretRequestError", "EnvironmentAuthorizationError"]);
218​
219/**
220 * Only known server errors pass their message through: anything else (transport or encoding
221 * failures) gets the generic copy, so the typed value can never surface in the UI.
222 */
223export function secretRequestFailureMessage(failure: unknown): string {
224 if (
225 typeof failure === "object" &&
226 failure !== null &&
227 "_tag" in failure &&
228 typeof failure._tag === "string" &&
229 USER_FACING_FAILURE_TAGS.has(failure._tag) &&
230 "message" in failure &&
231 typeof failure.message === "string" &&
232 failure.message.trim().length > 0
233 ) {
234 return failure.message;
235 }
236 return "Could not answer the request. Try again.";