Skip to content
Source
Sign inSign up

SecretRequests.test.ts

704 lines27.2 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import {
6 OrchestratorMcpRequestSecretInput,
7 ProjectId,
8 type OrchestrationV2ServerCommand,
9 SecretProviderId,
10 SecretProviderKind,
11 SecretRef,
12 ThreadId,
13 TurnItemId,
14} from "@t3tools/contracts";
15import { defineSecretProviderDriver } from "@t3tools/secret-provider-core/server/driver";
16import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
17import { defineSecretProvider } from "@t3tools/secret-provider-core/shared/definition";
18import * as Effect from "effect/Effect";
19import * as Exit from "effect/Exit";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as Metric from "effect/Metric";
23import * as Tracer from "effect/Tracer";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import * as PlatformError from "effect/PlatformError";
27import * as Schema from "effect/Schema";
28import * as TestClock from "effect/testing/TestClock";
29​
30import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
31import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
32import * as SecretRequests from "./SecretRequests.ts";
33​
34const threadId = ThreadId.make("thread-orchestrator");
35const turnItemId = TurnItemId.make("turn-item:secret-request:1");
36const projectId = ProjectId.make("project-1");
37const decodeSecretInput = Schema.decodeUnknownEffect(OrchestratorMcpRequestSecretInput);
38const decodeSecretInputExit = Schema.decodeUnknownExit(OrchestratorMcpRequestSecretInput);
39​
40const FAKE_KIND = SecretProviderKind.make("fake");
41const vault = SecretProviderId.make("vault");
42/** Reads `fake://` references from `values` while unlocked, as a password manager would. */
43const fakeVault = () => {
44 const state = { unlocked: true, values: new Map([["fake://github/token", "ghp_from_vault"]]) };
45 const driver = defineSecretProviderDriver({
46 kind: FAKE_KIND,
47 definition: defineSecretProvider({
48 kind: FAKE_KIND,
49 label: "Fake",
50 configSchema: Schema.Struct({}),
51 isReference: (reference) => reference.startsWith("fake://"),
52 referencePlaceholder: "fake://item",
53 invalidReferenceMessage: "Use fake://item.",
54 }),
55 read: (_config, references) =>
56 Effect.sync(() =>
57 references.map((reference) => (state.unlocked ? state.values.get(reference) : undefined)),
58 ),
59 });
60 return {
61 state,
62 layer: SecretResolver.layer({
63 drivers: [driver],
64 secretProviders: Effect.succeed({ [vault]: { kind: FAKE_KIND, label: "Vault", config: {} } }),
65 }).pipe(Layer.provide(Layer.merge(NodeServices.layer, NodeCrypto.layer))),
66 };
67};
68​
69const withService = <A, E>(
70 body: (input: {
71 readonly service: SecretRequests.SecretRequests["Service"];
72 readonly stored: Map<string, Uint8Array>;
73 readonly dispatched: Array<OrchestrationV2ServerCommand>;
74 }) => Effect.Effect<A, E>,
75 options: {
76 readonly threadId?: ThreadId;
77 readonly delivery?: "file";
78 readonly directory?: string;
79 readonly runStatus?: string;
80 readonly removeFails?: boolean;
81 readonly closedFirst?: boolean;
82 readonly failedRecords?: number;
83 readonly resolver?: Layer.Layer<SecretResolver.SecretResolver>;
84 } = {},
85) =>
86 Effect.gen(function* () {
87 const stored = new Map<string, Uint8Array>();
88 const dispatched: Array<OrchestrationV2ServerCommand> = [];
89 let secretStatus = "pending";
90 let failedRecords = options.failedRecords ?? 0;
91 const requestThreadId = options.threadId ?? threadId;
92 const layerDependencies = Layer.mergeAll(
93 NodeCrypto.layer,
94 NodeServices.layer,
95 options.resolver ?? fakeVault().layer,
96 Layer.succeed(
97 ServerSecretStore.ServerSecretStore,
98 ServerSecretStore.ServerSecretStore.of({
99 ...(options.directory === undefined ? {} : { directory: options.directory }),
100 get: (name) => Effect.yieldNow.pipe(Effect.as(Option.fromNullishOr(stored.get(name)))),
101 set: (name, value) => Effect.sync(() => void stored.set(name, value)),
102 create: (name, value) =>
103 stored.has(name)
104 ? Effect.fail(
105 new ServerSecretStore.SecretStorePersistError({
106 resource: name,
107 cause: new PlatformError.PlatformError(
108 new PlatformError.SystemError({
109 _tag: "AlreadyExists",
110 module: "FileSystem",
111 method: "open",
112 }),
113 ),
114 } as never),
115 )
116 : Effect.sync(() => void stored.set(name, value)),
117 getOrCreateRandom: (name, bytes) =>
118 Effect.sync(() => {
119 const existing = stored.get(name);
120 if (existing) return existing;
121 const value = new Uint8Array(bytes).fill(7);
122 stored.set(name, value);
123 return value;
124 }),
125 remove: (name) =>
126 options.removeFails
127 ? Effect.fail(
128 new ServerSecretStore.SecretStorePersistError({
129 resource: name,
130 cause: new Error("read-only"),
131 }),
132 )
133 : Effect.sync(() => void stored.delete(name)),
134 }),
135 ),
136 Layer.mock(ThreadManagementService.ThreadManagementService)({
137 getThreadRecords: () =>
138 Effect.succeed({
139 thread: { projectId },
140 runs: [{ id: "run-1", status: options.runStatus ?? "running" }],
141 turnItems: [
142 {
143 id: turnItemId,
144 threadId: requestThreadId,
145 runId: "run-1",
146 nodeId: "node-root",
147 type: "secret_request",
148 label: "GitHub token",
149 reason: "Used as GH_TOKEN.",
150 url: "https://github.com/settings/tokens",
151 ...(options.delivery === undefined ? {} : { delivery: options.delivery }),
152 secretStatus,
153 },
154 ],
155 } as never),
156 dispatch: (command) => {
157 if (command.type === "secret_request.record" && failedRecords > 0) {
158 failedRecords -= 1;
159 return Effect.fail(new Error("orchestrator unavailable") as never);
160 }
161 return Effect.sync(() => {
162 dispatched.push(command);
163 if (options.closedFirst && command.type === "secret_request.record") {
164 secretStatus = "cancelled";
165 } else if (command.type === "secret_request.record" && secretStatus === "pending") {
166 secretStatus = command.secretStatus;
167 }
168 return {} as never;
169 });
170 },
171 }),
172 );
173 return yield* Effect.gen(function* () {
174 const service = yield* SecretRequests.SecretRequests;
175 return yield* body({ service, stored, dispatched });
176 }).pipe(Effect.provide(SecretRequests.layer.pipe(Layer.provide(layerDependencies))));
177 });
178​
179const valuesOf = (stored: Map<string, Uint8Array>) =>
180 Array.from(stored.entries())
181 .filter(([name]) => name !== "secret-request-salt")
182 .map(([, bytes]) => new TextDecoder().decode(bytes));
183​
184it.effect("a saved answer becomes a one-use ref, and the thread only learns it was saved", () =>
185 withService(({ service, stored, dispatched }) =>
186 Effect.gen(function* () {
187 yield* service.answer({
188 threadId,
189 turnItemId,
190 answer: { type: "save", secret: "ghp_secret" },
191 });
192 assert.equal(dispatched.length, 1);
193 assert.include(dispatched[0] as object, {
194 type: "secret_request.record",
195 secretStatus: "saved",
196 url: "https://github.com/settings/tokens",
197 });
198 assert.notInclude(Object.values(dispatched[0] as object).map(String), "ghp_secret");
199​
200 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
201 assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret");
202 assert.isFalse(valuesOf(stored).some((value) => value.includes("ghp_secret")));
203 const again = yield* service.consume({ ref, projectId }).pipe(Effect.flip);
204 assert.include(again.message, "already used");
205 }),
206 ),
207);
208​
209it.effect("a secret provider reference is read once and saved like a typed value", () =>
210 withService(({ service, dispatched }) =>
211 Effect.gen(function* () {
212 yield* service.answer({
213 threadId,
214 turnItemId,
215 answer: {
216 type: "reference",
217 reference: { provider: vault, reference: "fake://github/token" },
218 },
219 });
220 assert.include(dispatched[0] as object, { secretStatus: "saved" });
221 assert.notInclude(JSON.stringify(dispatched), "fake://");
222 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
223 assert.equal(yield* service.consume({ ref, projectId }), "ghp_from_vault");
224 }),
225 ),
226);
227​
228it.effect(
229 "an unreadable reference leaves the request pending, and a retry after unlocking works",
230 () => {
231 const { state, layer } = fakeVault();
232 state.unlocked = false;
233 return withService(
234 ({ service, stored, dispatched }) =>
235 Effect.gen(function* () {
236 const reference = { provider: vault, reference: "fake://github/token" };
237 const locked = yield* service
238 .answer({ threadId, turnItemId, answer: { type: "reference", reference } })
239 .pipe(Effect.flip);
240 assert.equal(locked.reason, "reference_unreadable");
241 assert.include(locked.message, '"Vault"');
242 assert.deepEqual(dispatched, []);
243 assert.deepEqual(valuesOf(stored), []);
244​
245 state.unlocked = true;
246 yield* service.answer({ threadId, turnItemId, answer: { type: "reference", reference } });
247 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
248 assert.equal(yield* service.consume({ ref, projectId }), "ghp_from_vault");
249 }),
250 { resolver: layer },
251 );
252 },
253);
254​
255it.effect("a reference to a secret provider that is not configured fails", () =>
256 withService(({ service, dispatched }) =>
257 Effect.gen(function* () {
258 const error = yield* service
259 .answer({
260 threadId,
261 turnItemId,
262 answer: {
263 type: "reference",
264 reference: {
265 // Also an Object.prototype key, which must not pass for a configured provider.
266 provider: SecretProviderId.make("constructor"),
267 reference: "fake://github/token",
268 },
269 },
270 })
271 .pipe(Effect.flip);
272 assert.equal(error.reason, "reference_unreadable");
273 assert.include(error.message, '"constructor"');
274 assert.deepEqual(dispatched, []);
275 }),
276 ),
277);
278​
279it.effect("two concurrent uses of one ref hand the value out once", () =>
280 withService(({ service }) =>
281 Effect.gen(function* () {
282 yield* service.answer({
283 threadId,
284 turnItemId,
285 answer: { type: "save", secret: "ghp_secret" },
286 });
287 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
288 const results = yield* Effect.forEach(
289 [service.consume({ ref, projectId }), service.consume({ ref, projectId })],
290 Effect.result,
291 { concurrency: "unbounded" },
292 );
293 assert.deepEqual(results.map((result) => result._tag).toSorted(), ["Failure", "Success"]);
294 }),
295 ),
296);
297​
298it.effect("a save that loses to the card closing deletes the value and says so", () =>
299 withService(
300 ({ service, stored }) =>
301 Effect.gen(function* () {
302 const error = yield* service
303 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
304 .pipe(Effect.flip);
305 assert.equal(error.reason, "agent_stopped");
306 assert.isFalse(valuesOf(stored).some((value) => value.includes("ghp_secret")));
307 }),
308 { closedFirst: true },
309 ),
310);
311​
312it.effect("a save whose record failed can be saved again", () =>
313 withService(
314 ({ service }) =>
315 Effect.gen(function* () {
316 const failed = yield* service
317 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
318 .pipe(Effect.flip);
319 assert.equal(failed.reason, "record_failed");
320 yield* service.answer({
321 threadId,
322 turnItemId,
323 answer: { type: "save", secret: "ghp_secret" },
324 });
325 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
326 assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret");
327 }),
328 { failedRecords: 1 },
329 ),
330);
331​
332it.effect("a ref only works in the project it was entered for", () =>
333 withService(({ service }) =>
334 Effect.gen(function* () {
335 yield* service.answer({
336 threadId,
337 turnItemId,
338 answer: { type: "save", secret: "ghp_secret" },
339 });
340 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
341 const elsewhere = yield* service
342 .consume({ ref, projectId: ProjectId.make("project-other") })
343 .pipe(Effect.flip);
344 assert.include(elsewhere.message, "does not exist");
345 assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret");
346 }),
347 ),
348);
349​
350it.effect("declining stores nothing, and a request is answered once", () =>
351 withService(({ service, stored, dispatched }) =>
352 Effect.gen(function* () {
353 yield* service.answer({ threadId, turnItemId, answer: { type: "decline" } });
354 assert.deepEqual(valuesOf(stored), []);
355 assert.isTrue(Option.isNone(yield* service.savedRef({ threadId, turnItemId })));
356 const late = yield* service
357 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
358 .pipe(Effect.flip);
359 assert.include(late.message, "already answered");
360 assert.equal(dispatched.length, 1);
361 assert.deepEqual(valuesOf(stored), []);
362 }),
363 ),
364);
365​
366it.effect("traces and counts a saved answer without ever recording the value", () =>
367 Effect.gen(function* () {
368 const spans: Array<Tracer.NativeSpan> = [];
369 const tracer = Tracer.make({
370 span: (options) => {
371 const span = new Tracer.NativeSpan(options);
372 spans.push(span);
373 return span;
374 },
375 });
376 const counted = Metric.snapshot.pipe(
377 Effect.map((snapshots) => {
378 const found = snapshots.find(
379 (snapshot) =>
380 snapshot.id === "t3_secret_refs_consumed_total" &&
381 snapshot.attributes?.result === "used",
382 );
383 return found?.type === "Counter" ? Number(found.state.count) : 0;
384 }),
385 );
386 const before = yield* counted;
387 yield* withService(({ service }) =>
388 Effect.gen(function* () {
389 yield* service.answer({
390 threadId,
391 turnItemId,
392 answer: { type: "save", secret: "ghp_secret" },
393 });
394 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
395 yield* service.consume({ ref, projectId });
396 }),
397 ).pipe(Effect.withTracer(tracer));
398 assert.equal((yield* counted) - before, 1);
399 const recorded = spans.flatMap((span) => [
400 span.name,
401 ...Array.from(span.attributes.values(), String),
402 ]);
403 assert.include(recorded, "SecretRequests.answer");
404 assert.include(recorded, "SecretRequests.consume");
405 assert.isFalse(recorded.some((value) => value.includes("ghp_secret")));
406 }),
407);
408​
409it.effect("works in threads with long ids, such as a delegated subagent's", () => {
410 const delegatedThreadId = ThreadId.make(
411 `thread:delegated-task:command%3Amcp%3A${"a".repeat(36)}%3Adelegate-task%3Arelease-notes-v0.2.0-${"b".repeat(40)}`,
412 );
413 return withService(
414 ({ service, stored }) =>
415 Effect.gen(function* () {
416 yield* service.answer({
417 threadId: delegatedThreadId,
418 turnItemId,
419 answer: { type: "save", secret: "ghp_secret" },
420 });
421 assert.isTrue(Array.from(stored.keys()).every((name) => name.length < 100));
422 const ref = Option.getOrThrow(
423 yield* service.savedRef({ threadId: delegatedThreadId, turnItemId }),
424 );
425 assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret");
426 }),
427 { threadId: delegatedThreadId },
428 );
429});
430​
431it.effect("refuses an answer once the agent that asked has stopped", () =>
432 withService(
433 ({ service, stored, dispatched }) =>
434 Effect.gen(function* () {
435 const late = yield* service
436 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
437 .pipe(Effect.flip);
438 assert.include(late.message, "has stopped");
439 assert.deepEqual(valuesOf(stored), []);
440 assert.equal(dispatched.length, 0);
441 }),
442 { runStatus: "completed" },
443 ),
444);
445​
446it.effect("a used value that cannot be deleted is not handed out", () =>
447 withService(
448 ({ service }) =>
449 Effect.gen(function* () {
450 yield* service.answer({
451 threadId,
452 turnItemId,
453 answer: { type: "save", secret: "ghp_secret" },
454 });
455 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
456 const failed = yield* service.consume({ ref, projectId }).pipe(Effect.flip);
457 assert.include(failed.message, "Could not use");
458 }),
459 { removeFails: true },
460 ),
461);
462​
463const SECRET_FILE_TTL_MS = 24 * 60 * 60 * 1000;
464​
465it.effect("a file request moves its value to an owner-only file, once", () =>
466 Effect.gen(function* () {
467 const fs = yield* FileSystem.FileSystem;
468 const path = yield* Path.Path;
469 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" });
470 const file = yield* withService(
471 ({ service, stored, dispatched }) =>
472 Effect.gen(function* () {
473 yield* service.answer({
474 threadId,
475 turnItemId,
476 answer: { type: "save", secret: "cf_token'$(id)" },
477 });
478 assert.include(dispatched[0] as object, { secretStatus: "saved", delivery: "file" });
479 const file = yield* service.saveToFile({ threadId, turnItemId });
480 assert.equal(yield* fs.readFileString(file), "cf_token'$(id)");
481 assert.deepEqual(valuesOf(stored), []);
482 if ((yield* HostProcess.Platform) !== "win32") {
483 assert.equal((yield* fs.stat(file)).mode & 0o777, 0o600);
484 assert.equal((yield* fs.stat(path.dirname(file))).mode & 0o777, 0o700);
485 }
486 // A retried request_secret gets the same file; the value is never a ref again.
487 assert.equal(yield* service.saveToFile({ threadId, turnItemId }), file);
488 const ref = SecretRef.make(`secret-ref:${path.basename(file)}`);
489 const reused = yield* service.consume({ ref, projectId }).pipe(Effect.flip);
490 assert.equal(reused.reason, "ref_unavailable");
491 return file;
492 }),
493 { delivery: "file", directory },
494 );
495​
496 const { mtime } = yield* fs.stat(file);
497 yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS);
498 yield* withService(() => Effect.void, { directory });
499 assert.isTrue(yield* fs.exists(file));
500 yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS + 1);
501 yield* withService(() => Effect.void, { directory });
502 assert.isFalse(yield* fs.exists(file));
503 }).pipe(Effect.provide(NodeServices.layer)),
504);
505​
506it.effect("a failed file write keeps the value, so the request can retry", () =>
507 Effect.gen(function* () {
508 const fs = yield* FileSystem.FileSystem;
509 const path = yield* Path.Path;
510 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" });
511 yield* withService(
512 ({ service }) =>
513 Effect.gen(function* () {
514 yield* service.answer({
515 threadId,
516 turnItemId,
517 answer: { type: "save", secret: "cf_token" },
518 });
519 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
520 const name = ref.slice("secret-ref:".length);
521 // A non-empty directory where the partial file goes makes the write fail.
522 const partial = path.join(directory, "files", `${name}.partial`);
523 yield* fs.makeDirectory(path.join(partial, "block"), { recursive: true });
524 const failed = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip);
525 assert.equal(failed.reason, "store_failed");
526 yield* fs.remove(partial, { recursive: true });
527 const file = yield* service.saveToFile({ threadId, turnItemId });
528 assert.equal(yield* fs.readFileString(file), "cf_token");
529 }),
530 { delivery: "file", directory },
531 );
532 }).pipe(Effect.provide(NodeServices.layer)),
533);
534​
535it.effect("a file is not delivered while its stored copy cannot be removed", () =>
536 Effect.gen(function* () {
537 const fs = yield* FileSystem.FileSystem;
538 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" });
539 yield* withService(
540 ({ service }) =>
541 Effect.gen(function* () {
542 yield* service.answer({
543 threadId,
544 turnItemId,
545 answer: { type: "save", secret: "cf_token" },
546 });
547 const first = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip);
548 assert.equal(first.reason, "consume_failed");
549 // A retry finds the file but still will not hand it out until the removal works.
550 const retried = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip);
551 assert.equal(retried.reason, "consume_failed");
552 }),
553 { delivery: "file", directory, removeFails: true },
554 );
555 }).pipe(Effect.provide(NodeServices.layer)),
556);
557​
558it.effect("a secret the card promised to keep from the agent is never written to a file", () =>
559 withService(({ service, stored }) =>
560 Effect.gen(function* () {
561 yield* service.answer({
562 threadId,
563 turnItemId,
564 answer: { type: "save", secret: "ghp_secret" },
565 });
566 const refused = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip);
567 assert.equal(refused.reason, "not_a_file_request");
568 assert.isTrue(valuesOf(stored).some((value) => value.includes("ghp_secret")));
569 }),
570 ),
571);
572​
573it.effect("a pasted secret goes straight to an owner-only file, with no card", () =>
574 Effect.gen(function* () {
575 const fs = yield* FileSystem.FileSystem;
576 const path = yield* Path.Path;
577 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" });
578 const spans: Array<Tracer.NativeSpan> = [];
579 const tracer = Tracer.make({
580 span: (options) => {
581 const span = new Tracer.NativeSpan(options);
582 spans.push(span);
583 return span;
584 },
585 });
586 const pasted = "4829'$(id) 16";
587 const file = yield* withService(
588 ({ service, stored, dispatched }) =>
589 Effect.gen(function* () {
590 const file = yield* service.savePasted({
591 threadId,
592 requestNamespace: "session-a",
593 requestKey: "key-1",
594 value: pasted,
595 });
596 assert.equal(path.dirname(file), path.join(directory, "files"));
597 assert.match(path.basename(file), /^[0-9a-f]{32}$/);
598 assert.equal(yield* fs.readFileString(file), pasted);
599 if ((yield* HostProcess.Platform) !== "win32") {
600 assert.equal((yield* fs.stat(file)).mode & 0o777, 0o600);
601 assert.equal((yield* fs.stat(path.dirname(file))).mode & 0o777, 0o700);
602 }
603 // No card and no stored copy: the thread never hears of it.
604 assert.deepEqual(dispatched, []);
605 assert.deepEqual(valuesOf(stored), []);
606 // A retry with the same key gets the same file, unchanged even if the value differs.
607 assert.equal(
608 yield* service.savePasted({
609 threadId,
610 requestNamespace: "session-a",
611 requestKey: "key-1",
612 value: "other",
613 }),
614 file,
615 );
616 assert.equal(yield* fs.readFileString(file), pasted);
617 // Another key, or the same key from another provider session, gets its own file.
618 const other = yield* service.savePasted({
619 threadId,
620 requestNamespace: "session-a",
621 requestKey: "key-2",
622 value: "x",
623 });
624 assert.notEqual(other, file);
625 const otherSession = yield* service.savePasted({
626 threadId,
627 requestNamespace: "session-b",
628 requestKey: "key-1",
629 value: "y",
630 });
631 assert.notEqual(otherSession, file);
632 assert.equal(yield* fs.readFileString(file), pasted);
633 // Its name is not a ref to the value.
634 const ref = SecretRef.make(`secret-ref:${path.basename(file)}`);
635 const reused = yield* service.consume({ ref, projectId }).pipe(Effect.flip);
636 assert.equal(reused.reason, "ref_unavailable");
637 return file;
638 }).pipe(Effect.withTracer(tracer)),
639 { directory },
640 );
641 const recorded = spans.flatMap((span) => [
642 span.name,
643 ...Array.from(span.attributes.values(), String),
644 ]);
645 assert.include(recorded, "SecretRequests.savePasted");
646 assert.isFalse(recorded.some((value) => value.includes("4829")));
647​
648 const { mtime } = yield* fs.stat(file);
649 yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS);
650 yield* withService(() => Effect.void, { directory });
651 assert.isTrue(yield* fs.exists(file));
652 yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS + 1);
653 yield* withService(() => Effect.void, { directory });
654 assert.isFalse(yield* fs.exists(file));
655 }).pipe(Effect.provide(NodeServices.layer)),
656);
657​
658it.effect("a pasted secret keeps its exact bytes from the tool input to the file", () =>
659 Effect.gen(function* () {
660 const fs = yield* FileSystem.FileSystem;
661 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" });
662 const pasted = " pa ss\tword\t ";
663 const input = yield* decodeSecretInput({
664 label: "Staging password",
665 reason: "Signs in the QA account.",
666 value: pasted,
667 });
668 assert.equal(input.value, pasted);
669 const empty = decodeSecretInputExit({
670 label: "Staging password",
671 reason: "Signs in the QA account.",
672 value: "",
673 });
674 assert.isTrue(Exit.isFailure(empty));
675 for (const url of ["https://?state=x", "javascript:alert(1)"]) {
676 assert.isTrue(
677 Exit.isFailure(decodeSecretInputExit({ label: "Code", reason: "Sign in.", url })),
678 url,
679 );
680 }
681 const file = yield* withService(
682 ({ service }) =>
683 service.savePasted({
684 threadId,
685 requestNamespace: "session-a",
686 requestKey: "key-1",
687 value: input.value!,
688 }),
689 { directory },
690 );
691 assert.equal(yield* fs.readFileString(file), pasted);
692 }).pipe(Effect.provide(NodeServices.layer)),
693);
694​
695it.effect("a pasted secret is refused without a secrets directory", () =>
696 withService(({ service }) =>
697 Effect.gen(function* () {
698 const failed = yield* service
699 .savePasted({ threadId, requestNamespace: "session-a", requestKey: "key-1", value: "4829" })
700 .pipe(Effect.flip);
701 assert.equal(failed.reason, "store_failed");
702 }),
703 ),
704);