Skip to content
Source
Sign inSign up

SecretRequests.ts

395 lines17.0 KB

1// Answers stay in the secret store behind one-use refs; orchestration records only request status.
2// Consuming tools keep values out of transcripts, projections, clients and model context.
3// A `file` request instead moves its value to an owner-only file for the agent's own shell.
4// A secret the user pasted in chat goes straight to such a file, with no card.
5import {
6 CommandId,
7 SecretRef,
8 SecretRequestError,
9 type ProjectId,
10 type SecretReference,
11 type SecretRequestAnswerInput,
12 type ThreadId,
13} from "@t3tools/contracts";
14import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
15​
16import * as Clock from "effect/Clock";
17import * as Context from "effect/Context";
18import * as Mac from "effect/crypto/Mac";
19import * as Effect from "effect/Effect";
20import * as Hex from "effect/encoding/Hex";
21import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";
24import * as Path from "effect/Path";
25import * as Schedule from "effect/Schedule";
26import * as Schema from "effect/Schema";
27import * as Semaphore from "effect/Semaphore";
28​
29import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
30import * as Metrics from "../observability/Metrics.ts";
31import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
32​
33const SECRET_REF_PREFIX = "secret-ref:";
34const storeName = (ref: SecretRef) => `secret-request-${ref.slice(SECRET_REF_PREFIX.length)}`;
35const REF_PATTERN = /^secret-ref:[0-9a-f]{32}$/;
36const SECRET_REF_TTL_MS = 24 * 60 * 60 * 1000;
37const STORE_NAME_PATTERN = /^(secret-request-[0-9a-f]{32})\.bin$/;
38const FILE_NAME_PATTERN = /^[0-9a-f]{32}(\.partial)?$/;
39​
40const StoredSecret = Schema.fromJsonString(
41 Schema.Struct({ projectId: Schema.String, value: Schema.String, savedAt: Schema.Number }),
42);
43const encodeStored = Schema.encodeEffect(StoredSecret);
44const decodeStored = Schema.decodeUnknownOption(StoredSecret);
45​
46export class SecretRequests extends Context.Service<
47 SecretRequests,
48 {
49 readonly answer: (input: SecretRequestAnswerInput) => Effect.Effect<void, SecretRequestError>;
50 readonly savedRef: (input: {
51 readonly threadId: ThreadId;
52 readonly turnItemId: string;
53 }) => Effect.Effect<Option.Option<SecretRef>>;
54 readonly consume: (input: {
55 readonly ref: SecretRef;
56 readonly projectId: ProjectId;
57 }) => Effect.Effect<string, SecretRequestError>;
58 /** Moves a saved `file` request's value to an owner-only file and returns its path. */
59 readonly saveToFile: (input: {
60 readonly threadId: ThreadId;
61 readonly turnItemId: string;
62 }) => Effect.Effect<string, SecretRequestError>;
63 /** Writes a secret the user pasted in chat to an owner-only file and returns its path. */
64 readonly savePasted: (input: {
65 readonly threadId: ThreadId;
66 /** The provider session's namespace, so two sessions' keys never share a file. */
67 readonly requestNamespace: string;
68 readonly requestKey: string;
69 readonly value: string;
70 }) => Effect.Effect<string, SecretRequestError>;
71 }
72>()("t3/secrets/SecretRequests") {}
73​
74const make = Effect.gen(function* () {
75 const store = yield* ServerSecretStore.ServerSecretStore;
76 const fileSystem = yield* FileSystem.FileSystem;
77 const path = yield* Path.Path;
78 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
79 const resolver = yield* SecretResolver.SecretResolver;
80 // Beside the store's own files so the same owner-only directory and cleanup rules apply.
81 const filesDir = store.directory === undefined ? undefined : path.join(store.directory, "files");
82​
83 const mac = yield* Mac.Mac;
84 // Keyed by the salt's hex text, as refs saved by earlier versions were.
85 const refKey = yield* Mac.key(
86 Mac.hmacSha256,
87 new TextEncoder().encode(
88 Hex.encode(yield* store.getOrCreateRandom("secret-request-salt", 32).pipe(Effect.orDie)),
89 ),
90 ).pipe(Effect.orDie);
91 const refFor = (threadId: ThreadId, turnItemId: string) =>
92 mac.sign(refKey, new TextEncoder().encode(`${threadId}\u0000${turnItemId}`)).pipe(
93 Effect.orDie,
94 Effect.map((tag) => SecretRef.make(`${SECRET_REF_PREFIX}${Hex.encode(tag).slice(0, 32)}`)),
95 );
96​
97 const readReference = Effect.fn("SecretRequests.readReference")(function* (
98 reference: SecretReference,
99 ) {
100 // The resolver keeps failed reads for the server's life; a retry after unlocking must re-read.
101 yield* resolver.invalidate([reference]);
102 const [value] = yield* resolver.read([reference]);
103 if (value === undefined || value.trim() === "") {
104 return yield* new SecretRequestError({
105 reason: "reference_unreadable",
106 provider: yield* resolver.label(reference.provider),
107 });
108 }
109 return value;
110 });
111​
112 const answer: SecretRequests["Service"]["answer"] = Effect.fn("SecretRequests.answer")(
113 function* (input) {
114 yield* Effect.annotateCurrentSpan({
115 "orchestration_v2.thread_id": input.threadId,
116 "secret_request.answer": input.answer.type,
117 });
118 const records = yield* threadManagement
119 .getThreadRecords(input.threadId, ["runs", "turnItems"], {
120 turnItemTypes: ["secret_request"],
121 messageRoles: [],
122 })
123 .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "load_failed", cause })));
124 const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId);
125 if (item?.type !== "secret_request" || item.runId === null || item.nodeId === null) {
126 return yield* new SecretRequestError({ reason: "not_found" });
127 }
128 if (item.secretStatus !== "pending") {
129 return yield* new SecretRequestError({ reason: "already_answered" });
130 }
131 const run = records.runs.find((candidate) => candidate.id === item.runId);
132 if (run === undefined || ThreadManagementService.isTerminalRunStatus(run.status)) {
133 return yield* new SecretRequestError({ reason: "agent_stopped" });
134 }
135 const secret =
136 input.answer.type === "save"
137 ? input.answer.secret
138 : input.answer.type === "reference"
139 ? yield* readReference(input.answer.reference)
140 : undefined;
141 // Create before recording so racing answers cannot overwrite values or show saved before storage.
142 // Existing values on pending cards finish earlier saves whose recording failed.
143 if (secret !== undefined) {
144 const encoded = yield* encodeStored({
145 projectId: records.thread.projectId,
146 value: secret,
147 savedAt: yield* Clock.currentTimeMillis,
148 }).pipe(Effect.orDie);
149 yield* store
150 .create(
151 storeName(yield* refFor(input.threadId, item.id)),
152 new TextEncoder().encode(encoded),
153 )
154 .pipe(
155 Effect.catchIf(ServerSecretStore.isSecretAlreadyExistsError, () => Effect.void),
156 Effect.mapError(
157 (error) => new SecretRequestError({ reason: "store_failed", cause: error }),
158 ),
159 );
160 }
161 const secretStatus = secret === undefined ? "declined" : "saved";
162 yield* threadManagement
163 .dispatch({
164 type: "secret_request.record",
165 commandId: CommandId.make(`secret-request:${item.id}:${secretStatus}`),
166 threadId: input.threadId,
167 runId: item.runId,
168 nodeId: item.nodeId,
169 turnItemId: item.id,
170 label: item.label,
171 reason: item.reason,
172 ...(item.url === undefined ? {} : { url: item.url }),
173 ...(item.placeholder === undefined ? {} : { placeholder: item.placeholder }),
174 ...(item.delivery === undefined ? {} : { delivery: item.delivery }),
175 secretStatus,
176 })
177 .pipe(
178 Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })),
179 // Clear failed saves so the pending card can be answered again.
180 Effect.tapError(() =>
181 secret !== undefined
182 ? refFor(input.threadId, item.id).pipe(
183 Effect.flatMap((ref) => removeLogged(storeName(ref))),
184 )
185 : Effect.void,
186 ),
187 );
188 if (secret === undefined) return;
189 // The wait may have closed the card; delete unrecorded answers because no one receives their refs.
190 const recorded = yield* threadManagement
191 .getThreadRecords(input.threadId, ["turnItems"], {
192 turnItemTypes: ["secret_request"],
193 messageRoles: [],
194 })
195 .pipe(
196 Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })),
197 );
198 const card = recorded.turnItems.find((candidate) => candidate.id === item.id);
199 if (card?.type === "secret_request" && card.secretStatus !== "saved") {
200 yield* removeLogged(storeName(yield* refFor(input.threadId, item.id)));
201 return yield* new SecretRequestError({ reason: "agent_stopped" });
202 }
203 },
204 );
205​
206 const savedRef: SecretRequests["Service"]["savedRef"] = Effect.fn("SecretRequests.savedRef")(
207 function* (input) {
208 const ref = yield* refFor(input.threadId, input.turnItemId);
209 const stored = yield* store.get(storeName(ref)).pipe(Effect.orElseSucceed(Option.none));
210 return Option.map(stored, () => ref);
211 },
212 );
213​
214 const removeLogged = (name: string) =>
215 store.remove(name).pipe(
216 Effect.as(true),
217 Effect.catch((error) =>
218 Effect.logWarning("Could not delete a secret request value", {
219 errorTag: error._tag,
220 }).pipe(Effect.as(false)),
221 ),
222 );
223​
224 // Serialize get/remove so concurrent consumers cannot read the same one-use ref.
225 const consumeLock = yield* Semaphore.make(1);
226 const consume: SecretRequests["Service"]["consume"] = (input) =>
227 consumeRef(input).pipe(
228 consumeLock.withPermits(1),
229 Effect.tap(() => Metrics.increment(Metrics.secretRefsConsumedTotal, { result: "used" })),
230 Effect.tapError(() =>
231 Metrics.increment(Metrics.secretRefsConsumedTotal, { result: "rejected" }),
232 ),
233 Effect.withSpan("SecretRequests.consume"),
234 );
235​
236 /** A live ref's value, left in the store. */
237 const readRef = Effect.fn("SecretRequests.readRef")(function* (input: {
238 readonly ref: SecretRef;
239 readonly projectId: ProjectId;
240 }) {
241 if (!REF_PATTERN.test(input.ref))
242 return yield* new SecretRequestError({ reason: "invalid_ref" });
243 const stored = yield* store
244 .get(storeName(input.ref))
245 .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "read_failed", cause })));
246 const decoded = Option.flatMap(stored, (bytes) =>
247 decodeStored(new TextDecoder().decode(bytes)),
248 );
249 if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) {
250 return yield* new SecretRequestError({ reason: "ref_unavailable" });
251 }
252 if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) {
253 yield* removeLogged(storeName(input.ref));
254 return yield* new SecretRequestError({ reason: "ref_expired" });
255 }
256 return decoded.value.value;
257 });
258​
259 const consumeRef = Effect.fn("SecretRequests.consumeRef")(function* (input: {
260 readonly ref: SecretRef;
261 readonly projectId: ProjectId;
262 }) {
263 const value = yield* readRef(input);
264 // One use: the value moves into whatever consumed it. If it cannot be
265 // deleted, it is not handed out, so a ref is never used twice.
266 if (!(yield* removeLogged(storeName(input.ref)))) {
267 return yield* new SecretRequestError({ reason: "consume_failed" });
268 }
269 return value;
270 });
271​
272 const writeSecretFile = (dir: string, file: string, value: string) => {
273 const partial = `${file}.partial`;
274 return fileSystem.makeDirectory(dir, { recursive: true, mode: 0o700 }).pipe(
275 Effect.andThen(fileSystem.chmod(dir, 0o700)),
276 Effect.andThen(fileSystem.remove(partial, { force: true })),
277 // `wx` never follows or replaces an existing path.
278 Effect.andThen(
279 fileSystem.writeFile(partial, new TextEncoder().encode(value), {
280 flag: "wx",
281 mode: 0o600,
282 }),
283 ),
284 Effect.andThen(fileSystem.rename(partial, file)),
285 Effect.mapError((cause) => new SecretRequestError({ reason: "store_failed", cause })),
286 );
287 };
288​
289 const saveToFile: SecretRequests["Service"]["saveToFile"] = Effect.fn(
290 "SecretRequests.saveToFile",
291 )(function* (input) {
292 const records = yield* threadManagement
293 .getThreadRecords(input.threadId, ["turnItems"], {
294 turnItemTypes: ["secret_request"],
295 messageRoles: [],
296 })
297 .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "load_failed", cause })));
298 const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId);
299 // Only a card that told the user the agent's commands can read the value may write it out.
300 if (
301 item?.type !== "secret_request" ||
302 item.secretStatus !== "saved" ||
303 item.delivery !== "file"
304 ) {
305 return yield* new SecretRequestError({ reason: "not_a_file_request" });
306 }
307 if (filesDir === undefined) return yield* new SecretRequestError({ reason: "store_failed" });
308 const ref = yield* refFor(input.threadId, item.id);
309 const file = path.join(filesDir, ref.slice(SECRET_REF_PREFIX.length));
310 return yield* Effect.gen(function* () {
311 // One use: the file counts as delivered only once the stored copy is gone. A retry with the
312 // same clientRequestId gets the file the first call wrote and finishes that removal.
313 const removeStored = removeLogged(storeName(ref)).pipe(
314 Effect.flatMap((removed) =>
315 removed ? Effect.succeed(file) : new SecretRequestError({ reason: "consume_failed" }),
316 ),
317 );
318 if (yield* fileSystem.exists(file).pipe(Effect.orElseSucceed(() => false))) {
319 return yield* removeStored;
320 }
321 // The stored value goes only after the file is complete, so a failed write can retry.
322 const value = yield* readRef({ ref, projectId: records.thread.projectId });
323 yield* writeSecretFile(filesDir, file, value);
324 return yield* removeStored;
325 }).pipe(consumeLock.withPermits(1));
326 });
327​
328 const savePasted: SecretRequests["Service"]["savePasted"] = Effect.fn(
329 "SecretRequests.savePasted",
330 )(function* (input) {
331 if (filesDir === undefined) return yield* new SecretRequestError({ reason: "store_failed" });
332 const ref = yield* refFor(
333 input.threadId,
334 `pasted\u0000${input.requestNamespace}\u0000${input.requestKey}`,
335 );
336 const file = path.join(filesDir, ref.slice(SECRET_REF_PREFIX.length));
337 // A retry with the same clientRequestId gets the file the first call wrote, unchanged, so a
338 // command already holding that path never reads another value.
339 return yield* Effect.gen(function* () {
340 if (yield* fileSystem.exists(file).pipe(Effect.orElseSucceed(() => false))) return file;
341 yield* writeSecretFile(filesDir, file, input.value);
342 return file;
343 }).pipe(consumeLock.withPermits(1));
344 });
345​
346 const sweepExpired = Effect.gen(function* () {
347 if (store.directory === undefined) return;
348 const now = yield* Clock.currentTimeMillis;
349 const names = (yield* fileSystem.readDirectory(store.directory)).flatMap((file) => {
350 const match = STORE_NAME_PATTERN.exec(file);
351 return match?.[1] === undefined ? [] : [match[1]];
352 });
353 let removed = 0;
354 for (const name of names) {
355 const stored = yield* store.get(name).pipe(Effect.orElseSucceed(Option.none));
356 const decoded = Option.flatMap(stored, (bytes) =>
357 decodeStored(new TextDecoder().decode(bytes)),
358 );
359 if (Option.isSome(decoded) && now - decoded.value.savedAt <= SECRET_REF_TTL_MS) continue;
360 if (yield* removeLogged(name)) removed += 1;
361 }
362 yield* Effect.annotateCurrentSpan({ "secret_request.expired_removed": removed });
363 }).pipe(
364 Effect.catchCause((cause) => Effect.logWarning("Could not sweep expired secret refs", cause)),
365 Effect.withSpan("SecretRequests.sweepExpired"),
366 );
367 const sweepExpiredFiles = Effect.gen(function* () {
368 if (filesDir === undefined || !(yield* fileSystem.exists(filesDir))) return;
369 const now = yield* Clock.currentTimeMillis;
370 let removed = 0;
371 for (const name of yield* fileSystem.readDirectory(filesDir)) {
372 if (!FILE_NAME_PATTERN.test(name)) continue;
373 const file = path.join(filesDir, name);
374 const { mtime } = yield* fileSystem.stat(file);
375 if (Option.exists(mtime, (time) => now - time.getTime() <= SECRET_REF_TTL_MS)) continue;
376 yield* fileSystem.remove(file);
377 removed += 1;
378 }
379 yield* Effect.annotateCurrentSpan({ "secret_request.expired_files_removed": removed });
380 }).pipe(
381 Effect.catchCause((cause) => Effect.logWarning("Could not sweep expired secret files", cause)),
382 Effect.withSpan("SecretRequests.sweepExpiredFiles"),
383 );
384 const sweep = Effect.andThen(sweepExpired, sweepExpiredFiles);
385 yield* sweep;
386 yield* sweep.pipe(
387 Effect.delay("1 hour"),
388 Effect.repeat(Schedule.spaced("1 hour")),
389 Effect.forkScoped,
390 );
391​
392 return SecretRequests.of({ answer, savedRef, consume, saveToFile, savePasted });
393});
394​
395export const layer = Layer.effect(SecretRequests, make);