cassette.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
  2. import * as fs from "node:fs"
  3. import * as path from "node:path"
  4. import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
  5. import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
  6. const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
  7. export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
  8. cassetteName: Schema.String,
  9. }) {
  10. override get message() {
  11. return `Cassette "${this.cassetteName}" not found`
  12. }
  13. }
  14. export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
  15. cassetteName: Schema.String,
  16. findings: Schema.Array(SecretFindingSchema),
  17. }) {
  18. override get message() {
  19. return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
  20. .map((finding) => `${finding.path} (${finding.reason})`)
  21. .join(", ")}`
  22. }
  23. }
  24. export interface Interface {
  25. readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
  26. readonly append: (
  27. name: string,
  28. interaction: Interaction,
  29. metadata?: CassetteMetadata,
  30. ) => Effect.Effect<void, UnsafeCassetteError>
  31. readonly exists: (name: string) => Effect.Effect<boolean>
  32. readonly list: () => Effect.Effect<ReadonlyArray<string>>
  33. }
  34. export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
  35. const cassettePath = (directory: string, name: string) => {
  36. if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
  37. throw new Error(`Invalid cassette name "${name}"`)
  38. const root = path.resolve(directory)
  39. const target = path.resolve(root, `${name}.json`)
  40. const relative = path.relative(root, target)
  41. if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
  42. throw new Error(`Invalid cassette name "${name}"`)
  43. return target
  44. }
  45. export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
  46. fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
  47. const buildCassette = (
  48. name: string,
  49. interactions: ReadonlyArray<Interaction>,
  50. metadata: CassetteMetadata | undefined,
  51. ): Cassette => ({
  52. version: 1,
  53. metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
  54. interactions,
  55. })
  56. const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
  57. const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
  58. const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
  59. findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
  60. export const fileSystem = (
  61. options: { readonly directory?: string } = {},
  62. ): Layer.Layer<Service, never, FileSystem.FileSystem> =>
  63. Layer.effect(
  64. Service,
  65. Effect.gen(function* () {
  66. const fs = yield* FileSystem.FileSystem
  67. const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
  68. const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
  69. const appendLock = yield* Semaphore.make(1)
  70. const pathFor = (name: string) => cassettePath(directory, name)
  71. const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
  72. Effect.gen(function* () {
  73. const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
  74. const nested = yield* Effect.forEach(entries, (entry) => {
  75. const full = path.join(current, entry)
  76. return fs.stat(full).pipe(
  77. Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
  78. Effect.catch(() => Effect.succeed([] as string[])),
  79. )
  80. })
  81. return nested.flat()
  82. })
  83. return Service.of({
  84. read: (name) =>
  85. fs.readFileString(pathFor(name)).pipe(
  86. Effect.map((raw) => parseCassette(raw).interactions),
  87. Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
  88. ),
  89. append: (name, interaction, metadata) =>
  90. appendLock.withPermit(
  91. Effect.gen(function* () {
  92. const entry = recorded.get(name) ?? { interactions: [], findings: [] }
  93. const interactions = [...entry.interactions, interaction]
  94. const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
  95. const cassette = buildCassette(name, interactions, metadata)
  96. const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
  97. yield* failIfUnsafe(name, findings)
  98. const target = pathFor(name)
  99. yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
  100. const temporary = `${target}.${crypto.randomUUID()}.tmp`
  101. yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
  102. Effect.flatMap(() => fs.rename(temporary, target)),
  103. Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
  104. Effect.orDie,
  105. )
  106. recorded.set(name, { interactions, findings: interactionFindings })
  107. }),
  108. ),
  109. exists: (name) =>
  110. fs.access(pathFor(name)).pipe(
  111. Effect.as(true),
  112. Effect.catch(() => Effect.succeed(false)),
  113. ),
  114. list: () =>
  115. walk(directory).pipe(
  116. Effect.map((files) =>
  117. files
  118. .filter((file) => file.endsWith(".json"))
  119. .map((file) =>
  120. path
  121. .relative(directory, file)
  122. .replace(/\\/g, "/")
  123. .replace(/\.json$/, ""),
  124. )
  125. .toSorted((a, b) => a.localeCompare(b)),
  126. ),
  127. ),
  128. })
  129. }),
  130. )
  131. export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
  132. Layer.sync(Service, () => {
  133. const stored = new Map<string, Interaction[]>(
  134. Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
  135. )
  136. const accumulatedFindings = new Map<string, SecretFinding[]>()
  137. const appendLock = Semaphore.makeUnsafe(1)
  138. return Service.of({
  139. read: (name) =>
  140. stored.has(name)
  141. ? Effect.succeed(stored.get(name) ?? [])
  142. : Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
  143. append: (name, interaction, metadata) =>
  144. appendLock.withPermit(
  145. Effect.suspend(() => {
  146. const interactions = [...(stored.get(name) ?? []), interaction]
  147. const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
  148. const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
  149. return failIfUnsafe(name, allFindings).pipe(
  150. Effect.tap(() =>
  151. Effect.sync(() => {
  152. stored.set(name, interactions)
  153. accumulatedFindings.set(name, findings)
  154. }),
  155. ),
  156. )
  157. }),
  158. ),
  159. exists: (name) => Effect.sync(() => stored.has(name)),
  160. list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
  161. })
  162. })