| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
- import * as fs from "node:fs"
- import * as path from "node:path"
- import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
- import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
- const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
- export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
- cassetteName: Schema.String,
- }) {
- override get message() {
- return `Cassette "${this.cassetteName}" not found`
- }
- }
- export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
- cassetteName: Schema.String,
- findings: Schema.Array(SecretFindingSchema),
- }) {
- override get message() {
- return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
- .map((finding) => `${finding.path} (${finding.reason})`)
- .join(", ")}`
- }
- }
- export interface Interface {
- readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
- readonly append: (
- name: string,
- interaction: Interaction,
- metadata?: CassetteMetadata,
- ) => Effect.Effect<void, UnsafeCassetteError>
- readonly exists: (name: string) => Effect.Effect<boolean>
- readonly list: () => Effect.Effect<ReadonlyArray<string>>
- }
- export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
- const cassettePath = (directory: string, name: string) => {
- if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
- throw new Error(`Invalid cassette name "${name}"`)
- const root = path.resolve(directory)
- const target = path.resolve(root, `${name}.json`)
- const relative = path.relative(root, target)
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
- throw new Error(`Invalid cassette name "${name}"`)
- return target
- }
- export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
- fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
- const buildCassette = (
- name: string,
- interactions: ReadonlyArray<Interaction>,
- metadata: CassetteMetadata | undefined,
- ): Cassette => ({
- version: 1,
- metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
- interactions,
- })
- const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
- const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
- const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
- findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
- export const fileSystem = (
- options: { readonly directory?: string } = {},
- ): Layer.Layer<Service, never, FileSystem.FileSystem> =>
- Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* FileSystem.FileSystem
- const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
- const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
- const appendLock = yield* Semaphore.make(1)
- const pathFor = (name: string) => cassettePath(directory, name)
- const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
- Effect.gen(function* () {
- const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
- const nested = yield* Effect.forEach(entries, (entry) => {
- const full = path.join(current, entry)
- return fs.stat(full).pipe(
- Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
- Effect.catch(() => Effect.succeed([] as string[])),
- )
- })
- return nested.flat()
- })
- return Service.of({
- read: (name) =>
- fs.readFileString(pathFor(name)).pipe(
- Effect.map((raw) => parseCassette(raw).interactions),
- Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
- ),
- append: (name, interaction, metadata) =>
- appendLock.withPermit(
- Effect.gen(function* () {
- const entry = recorded.get(name) ?? { interactions: [], findings: [] }
- const interactions = [...entry.interactions, interaction]
- const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
- const cassette = buildCassette(name, interactions, metadata)
- const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
- yield* failIfUnsafe(name, findings)
- const target = pathFor(name)
- yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
- const temporary = `${target}.${crypto.randomUUID()}.tmp`
- yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
- Effect.flatMap(() => fs.rename(temporary, target)),
- Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
- Effect.orDie,
- )
- recorded.set(name, { interactions, findings: interactionFindings })
- }),
- ),
- exists: (name) =>
- fs.access(pathFor(name)).pipe(
- Effect.as(true),
- Effect.catch(() => Effect.succeed(false)),
- ),
- list: () =>
- walk(directory).pipe(
- Effect.map((files) =>
- files
- .filter((file) => file.endsWith(".json"))
- .map((file) =>
- path
- .relative(directory, file)
- .replace(/\\/g, "/")
- .replace(/\.json$/, ""),
- )
- .toSorted((a, b) => a.localeCompare(b)),
- ),
- ),
- })
- }),
- )
- export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
- Layer.sync(Service, () => {
- const stored = new Map<string, Interaction[]>(
- Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
- )
- const accumulatedFindings = new Map<string, SecretFinding[]>()
- const appendLock = Semaphore.makeUnsafe(1)
- return Service.of({
- read: (name) =>
- stored.has(name)
- ? Effect.succeed(stored.get(name) ?? [])
- : Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
- append: (name, interaction, metadata) =>
- appendLock.withPermit(
- Effect.suspend(() => {
- const interactions = [...(stored.get(name) ?? []), interaction]
- const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
- const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
- return failIfUnsafe(name, allFindings).pipe(
- Effect.tap(() =>
- Effect.sync(() => {
- stored.set(name, interactions)
- accumulatedFindings.set(name, findings)
- }),
- ),
- )
- }),
- ),
- exists: (name) => Effect.sync(() => stored.has(name)),
- list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
- })
- })
|