Compare commits

...

1 Commits

Author SHA1 Message Date
Max Paulus 🥪 70ff4914ff add nock library to Cline CLI 2026-03-13 11:02:07 -07:00
6 changed files with 363 additions and 6 deletions
+1
View File
@@ -235,6 +235,7 @@ const sharedOptions: Partial<esbuild.BuildOptions> = {
"pino",
"pino-roll",
"@vscode/ripgrep", // Uses __dirname to locate the binary
"nock", // VCR support — devDependency, dynamically imported only when CLINE_VCR is set
],
supported: { "top-level-await": true },
}
+1
View File
@@ -77,6 +77,7 @@
"dotenv": "^16.4.5",
"esbuild": "^0.25.0",
"ink-testing-library": "^4.0.0",
"nock": "^15.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.4.5",
"vitest": "^4.0.17"
+5 -1
View File
@@ -45,6 +45,7 @@ import { applyProviderConfig } from "./utils/provider-config"
import { getValidCliProviders, isValidCliProvider } from "./utils/providers"
import { findMostRecentTaskForWorkspace } from "./utils/task-history"
import { autoUpdateOnStartup, checkForUpdates } from "./utils/update"
import { initVcr } from "./utils/vcr"
import { initializeCliContext } from "./vscode-context"
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
@@ -1148,7 +1149,10 @@ program
}
})
// Parse and run
// Initialize VCR (nock-based HTTP record/playback) before parsing commands.
// This must happen before any HTTP requests are made so nock can intercept them.
// Does nothing if CLINE_VCR env var is not set.
if (process.env.VITEST !== "true") {
await initVcr(process.env.CLINE_VCR)
program.parse()
}
+261
View File
@@ -0,0 +1,261 @@
/**
* VCR (Video Cassette Recorder) for HTTP requests.
*
* Uses nock to record and replay HTTP interactions, enabling deterministic
* testing of the CLI without making real API calls.
*
* Environment variables:
* CLINE_VCR - "record" to record HTTP requests, "playback" to replay them
* CLINE_VCR_CASSETTE - Path to the cassette file (default: ./vcr-cassette.json)
* CLINE_VCR_FILTER - Substring to filter recorded/replayed request paths.
* Defaults to "chat/completions" so only inference requests
* are captured. Set to "" to record/replay all requests.
*
* Usage:
* # Record only inference requests (default filter)
* CLINE_VCR=record CLINE_VCR_CASSETTE=./fixtures/my-test.json cline task "hello"
*
* # Replay — auth/S3/etc. requests go through normally, only inference is mocked
* CLINE_VCR=playback CLINE_VCR_CASSETTE=./fixtures/my-test.json cline task "hello"
*
* # Record everything (no filter)
* CLINE_VCR=record CLINE_VCR_FILTER="" CLINE_VCR_CASSETTE=./fixtures/all.json cline task "hello"
*
* Note on net.ts / nock interception:
* The CLI normally uses undici's fetch directly (IS_STANDALONE=true path in shared/net.ts).
* When CLINE_VCR is set, shared/net.ts falls back to globalThis.fetch so that nock's
* recorder/interceptors — which patch globalThis.fetch — can intercept requests.
*/
import fs from "node:fs"
import path from "node:path"
import type nock from "nock"
import type { Definition } from "nock"
type VcrMode = "record" | "playback"
// ── Response body sanitization ──────────────────────────────────────────
// Keys whose *values* are redacted in response bodies (matched case-insensitively).
const SENSITIVE_RESPONSE_KEYS = new Set([
"accesskeyid", // AWS access key ID
"secretaccesskey", // AWS secret access key
"idtoken", // JWT / OIDC id tokens
"refreshtoken", // Refresh tokens
"access_token", // OAuth access tokens
"refresh_token", // OAuth refresh tokens
])
// Regex patterns that are redacted from any string value regardless of key name.
const SENSITIVE_PATTERNS: { pattern: RegExp; replacement: string }[] = [
// AWS access key IDs (always start with AKIA)
{ pattern: /AKIA[A-Z0-9]{16}/g, replacement: "AKIA_REDACTED_KEY_ID" },
]
/**
* Deep-sanitize a value, redacting known sensitive keys and patterns.
* Handles objects, arrays, plain strings, and JSON-encoded strings
* (e.g. the remote-config `value` field which is a JSON string inside JSON).
*/
function sanitizeResponseValue(obj: unknown): unknown {
if (obj === null || obj === undefined) {
return obj
}
if (typeof obj === "string") {
// First, try to parse as embedded JSON and sanitize recursively
try {
const parsed = JSON.parse(obj)
if (typeof parsed === "object" && parsed !== null) {
return JSON.stringify(sanitizeResponseValue(parsed))
}
} catch {
// Not JSON — fall through to pattern-based sanitization
}
// Apply regex patterns to plain string values
let result = obj
for (const { pattern, replacement } of SENSITIVE_PATTERNS) {
result = result.replace(pattern, replacement)
}
return result
}
if (Array.isArray(obj)) {
return obj.map(sanitizeResponseValue)
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (SENSITIVE_RESPONSE_KEYS.has(key.toLowerCase()) && typeof value === "string") {
result[key] = "REDACTED"
} else {
result[key] = sanitizeResponseValue(value)
}
}
return result
}
return obj
}
/**
* Sanitize a single recorded nock Definition, stripping sensitive data
* from headers, request bodies, and response bodies.
*/
function sanitizeRecording(rec: Definition): Definition {
const cleaned = { ...rec }
// Strip raw response headers (contain request IDs, dates, etc.)
delete (cleaned as any).rawHeaders
// Remove sensitive request headers if present
if (cleaned.reqheaders) {
delete cleaned.reqheaders.authorization
delete cleaned.reqheaders.Authorization
delete cleaned.reqheaders["x-api-key"]
delete cleaned.reqheaders["X-Api-Key"]
}
// Remove request body (may contain prompts, API keys, etc.)
if (cleaned.body) {
delete cleaned.body
}
// Deep-sanitize response body for embedded secrets (S3 creds, tokens, etc.)
if (cleaned.response !== undefined) {
cleaned.response = sanitizeResponseValue(cleaned.response) as Definition["response"]
}
return cleaned
}
interface VcrConfig {
mode: VcrMode
cassettePath: string
/** Only record/replay requests whose path includes this string. "" = no filter. */
filter: string
}
function getVcrConfig(vcrMode: string | undefined): VcrConfig | null {
if (!vcrMode) {
return null
}
if (!process.env.CLINE_VCR_CASSETTE) {
return null
}
if (vcrMode !== "record" && vcrMode !== "playback") {
process.stderr.write(`[VCR] Invalid CLINE_VCR value: "${vcrMode}". Expected "record" or "playback".\n`)
process.exit(1)
}
const cassettePath = path.resolve(process.env.CLINE_VCR_CASSETTE)
const filter = process.env.CLINE_VCR_FILTER ?? ""
return { mode: vcrMode, cassettePath, filter }
}
async function importNock(): Promise<typeof nock> {
try {
const mod = await import("nock")
return mod.default as typeof nock
} catch {
process.stderr.write(
"[VCR] nock is required for VCR mode but is not installed.\n" +
" Install it with: npm install -D nock\n" +
" (nock is a devDependency in the cli package)\n",
)
process.exit(1)
}
}
async function startRecordingRequests(cassettePath: string, filter: string): Promise<void> {
const nock = await importNock()
// Start recording — requests pass through to real servers and are captured
nock.recorder.rec({
output_objects: true,
dont_print: true,
enable_reqheaders_recording: false,
})
const filterDesc = filter ? `matching path "*${filter}*"` : "all paths"
process.stderr.write(`[VCR] Recording HTTP requests (${filterDesc}). Cassette will be saved to: ${cassettePath}\n`)
// Save recordings on process exit (synchronous — required by 'exit' event)
const saveRecordings = () => {
let recordings = nock.recorder.play() as Definition[]
// Filter to only matching paths if a filter is set
if (filter) {
recordings = recordings.filter((rec: Definition) => typeof rec.path === "string" && rec.path.includes(filter))
}
if (recordings.length === 0) {
process.stderr.write(`[VCR] No HTTP requests matching "${filter}" were recorded.\n`)
return
}
// Ensure output directory exists
const dir = path.dirname(cassettePath)
fs.mkdirSync(dir, { recursive: true })
// Strip sensitive data from recorded interactions
const sanitized = recordings.map(sanitizeRecording)
fs.writeFileSync(cassettePath, JSON.stringify(sanitized, null, 2))
process.stderr.write(`[VCR] Saved ${sanitized.length} recorded HTTP interaction(s) to ${cassettePath}\n`)
}
// The 'exit' handler fires for process.exit(), SIGINT default, etc.
// It is synchronous-only, which is fine since we use writeFileSync.
process.on("exit", saveRecordings)
process.on("SIGTERM", saveRecordings)
}
async function startPlayingBackRequests(cassettePath: string, filter: string): Promise<void> {
const nock = await importNock()
if (!fs.existsSync(cassettePath)) {
process.stderr.write(`[VCR] Cassette file not found: ${cassettePath}\n`)
process.exit(1)
}
const recordings: Definition[] = JSON.parse(fs.readFileSync(cassettePath, "utf-8"))
// In filtered playback, we do NOT block all network connections.
// Only the recorded paths are intercepted; everything else (auth, S3, etc.)
// hits real servers so those flows work normally.
if (!filter) {
// No filter: block all real connections to ensure full isolation
nock.disableNetConnect()
nock.enableNetConnect("127.0.0.1")
nock.enableNetConnect("localhost")
}
// Define recorded interactions as nock interceptors
const nocks = nock.define(recordings)
const filterDesc = filter
? `(only paths matching "*${filter}*", all other requests go through normally)`
: "(all requests intercepted)"
process.stderr.write(`[VCR] Playing back ${nocks.length} recorded HTTP interaction(s) from ${cassettePath} ${filterDesc}\n`)
}
/**
* Initialize VCR mode based on environment variables.
* Must be called early in the CLI startup, before HTTP requests are made.
*
* Does nothing if CLINE_VCR is not set.
*/
export async function initVcr(vcrMode: string | undefined): Promise<void> {
const config = getVcrConfig(vcrMode)
if (!config) {
return
}
if (config.mode === "record") {
await startRecordingRequests(config.cassettePath, config.filter)
} else {
await startPlayingBackRequests(config.cassettePath, config.filter)
}
}
+86
View File
@@ -201,6 +201,7 @@
"dotenv": "^16.4.5",
"esbuild": "^0.25.0",
"ink-testing-library": "^4.0.0",
"nock": "^15.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.4.5",
"vitest": "^4.0.17"
@@ -3896,6 +3897,24 @@
}
}
},
"node_modules/@mswjs/interceptors": {
"version": "0.39.8",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz",
"integrity": "sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@open-draft/deferred-promise": "^2.2.0",
"@open-draft/logger": "^0.3.0",
"@open-draft/until": "^2.0.0",
"is-node-process": "^1.2.0",
"outvariant": "^1.4.3",
"strict-event-emitter": "^0.5.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3931,6 +3950,31 @@
"node": ">= 8"
}
},
"node_modules/@open-draft/deferred-promise": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
"dev": true,
"license": "MIT"
},
"node_modules/@open-draft/logger": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
"integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-node-process": "^1.2.0",
"outvariant": "^1.4.0"
}
},
"node_modules/@open-draft/until": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"dev": true,
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -14503,6 +14547,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-node-process": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
"dev": true,
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -15494,6 +15545,13 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -17325,6 +17383,20 @@
"path-to-regexp": "^8.1.0"
}
},
"node_modules/nock": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/nock/-/nock-15.0.0.tgz",
"integrity": "sha512-EoAVk4Y8Yv4JUQz62sv8zmv+DoBblD/pht/q7aW/td1WietaFWrivzhMGGCLmx2qjpLcOrbyammedW8IRUU5TA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mswjs/interceptors": "^0.39.5",
"json-stringify-safe": "^5.0.1"
},
"engines": {
"node": ">=18.20.0 <20 || >=20.12.1"
}
},
"node_modules/node-abi": {
"version": "3.87.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
@@ -18359,6 +18431,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/outvariant": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
"integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
"dev": true,
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -21074,6 +21153,13 @@
"text-decoder": "^1.1.0"
}
},
"node_modules/strict-event-emitter": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+9 -5
View File
@@ -112,18 +112,23 @@ let mockFetch: typeof globalThis.fetch | undefined
export const fetch: typeof globalThis.fetch = (() => {
// Note: Don't use Logger here; it may not be initialized.
let baseFetch: typeof globalThis.fetch = globalThis.fetch
let baseFetch: typeof globalThis.fetch | null = null
// Note: See esbuild.mjs, process.env.IS_STANDALONE is statically rewritten
// to "true" or "false" (as strings) in the JetBrains/CLI build.
// We must use explicit string comparison because "false" is truthy in JS.
if (process.env.IS_STANDALONE === "true") {
if (process.env.IS_STANDALONE === "true" && !process.env.CLINE_VCR) {
// Configure undici with ProxyAgent
// Skip when CLINE_VCR is set so nock can intercept via globalThis.fetch
const agent = new EnvHttpProxyAgent({})
setGlobalDispatcher(agent)
baseFetch = undiciFetch as any as typeof globalThis.fetch
}
return (input: string | URL | Request, init?: RequestInit): Promise<Response> => (mockFetch || baseFetch)(input, init)
// When CLINE_VCR is set, baseFetch is null and we reference globalThis.fetch
// lazily at call time. This is required because nock patches globalThis.fetch
// AFTER module initialization, so an eagerly-captured reference would bypass nock.
return (input: string | URL | Request, init?: RequestInit): Promise<Response> =>
(mockFetch || baseFetch || globalThis.fetch)(input, init)
})()
/**
@@ -145,9 +150,8 @@ export function mockFetchForTesting<T>(theFetch: typeof globalThis.fetch, callba
return result.finally(() => {
mockFetch = originalMockFetch
}) as typeof result
} else {
return result
}
return result
} finally {
if (willResetSync) {
mockFetch = originalMockFetch