{"record":{"id":"8979feb024d21b63","repo":"pbakaus/impeccable","slug":"unsupported-live-copy-edit-ai-runner-provider","errorCode":null,"errorMessage":"Unsupported live copy-edit AI runner: ${provider}","messagePattern":"Unsupported live copy-edit AI runner: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"plugin/skills/impeccable/scripts/live-copy-edit-agent.mjs","lineNumber":128,"sourceCode":"    const raw = await opts.applyBatchToSource(batch, { repair: batch?.repair || null });\n    return normalizeBatchResult(raw || {});\n  }\n  if (!provider) {\n    throw new Error(describeNoProviderError({ env }));\n  }\n\n  const prompt = buildCopyEditBatchPrompt(batch, { cwd });\n  const outDir = opts.outDir || fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-copy-batch-'));\n  fs.mkdirSync(outDir, { recursive: true });\n  const resultPath = path.join(outDir, 'result.json');\n  const logPath = path.join(outDir, 'agent.log');\n\n  if (provider === 'codex') {\n    await runCodex(prompt, { cwd, env, resultPath, logPath, timeoutMs: opts.timeoutMs });\n  } else if (provider === 'claude') {\n    await runClaude(prompt, { cwd, env, resultPath, logPath, timeoutMs: opts.timeoutMs });\n  } else {\n    throw new Error(`Unsupported live copy-edit AI runner: ${provider}`);\n  }\n\n  const output = fs.existsSync(resultPath) ? fs.readFileSync(resultPath, 'utf-8') : '';\n  const parsed = parseCopyEditBatchResult(output);\n  if (parsed) return parsed;\n\n  const tail = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf-8').slice(-1200) : output.slice(-1200);\n  throw new Error('AI copy-edit batch did not return a valid completion payload. ' + tail.trim());\n}\n\nexport function runCopyEditPostApplyChecks({ cwd = process.cwd(), files = [] } = {}) {\n  const failures = [];\n  const warnings = [];\n  const uniqueFiles = [...new Set((files || []).filter((file) => typeof file === 'string' && file.trim()))];\n  for (const relativeFile of uniqueFiles) {\n    const file = path.resolve(cwd, relativeFile);\n    if (!isPathInsideOrEqual(cwd, file) || !fs.existsSync(file)) {\n      warnings.push({ file: relativeFile, reason: 'file_missing_or_outside_cwd' });","sourceCodeStart":110,"sourceCodeEnd":146,"githubUrl":"https://github.com/pbakaus/impeccable/blob/d14711ae3d1a1dd62dee61a358d27f107c51ccd0/plugin/skills/impeccable/scripts/live-copy-edit-agent.mjs#L110-L146","documentation":"Defensive throw inside runCopyEditBatchAgent(): provider is truthy (so it got past the !provider and mock/chat branches) but is not 'codex' or 'claude'. The internal chooseCopyEditAgent() only ever returns 'mock'|'chat'|'codex'|'claude'|null, so in normal operation this branch is unreachable. It fires when a caller passes opts.provider explicitly with an unsupported string.","triggerScenarios":"Calling runCopyEditBatchAgent(batch, { provider: 'gemini' }) or any value outside the recognised set; a typo like 'Claude' (capitalised) or 'codex ' (trailing space); a future provider name added to env but not yet implemented in this if/else. The mock and chat paths are handled earlier, so only an unknown non-empty string reaches here.","commonSituations":"Hardcoding a provider for testing with a misspelled name; IMPECCABLE_LIVE_COPY_AGENT set to an experimental name the build doesn't recognise but caller forwards verbatim; downstream code that forwards user input as the provider without an allowlist.","solutions":["Use one of the supported provider values: 'mock', 'chat', 'codex', or 'claude' (case-sensitive, lowercase).","If passing opts.provider programmatically, validate it against ['mock','chat','codex','claude'] before calling.","Leave opts.provider unset and let chooseCopyEditAgent({ env }) pick, which can only return valid values or null (null yields the descriptive error 43 instead).","For a new runner, extend both chooseCopyEditAgent() and this if/else; this throw means the dispatch table is out of sync."],"exampleFix":"// before\nawait runCopyEditBatchAgent(batch, { provider: agentName }); // agentName='gemini'\n\n// after\nconst ALLOWED = new Set(['mock', 'chat', 'codex', 'claude']);\nif (agentName && !ALLOWED.has(agentName)) {\n  throw new Error(`Unknown copy-edit provider: ${agentName}`);\n}\nawait runCopyEditBatchAgent(batch, { provider: ALLOWED.has(agentName) ? agentName : undefined });","handlingStrategy":"validation","validationCode":"const ALLOWED_PROVIDERS = new Set(['mock', 'chat', 'codex', 'claude']);\nfunction isValidProvider(value) {\n  return value == null || ALLOWED_PROVIDERS.has(value);\n}\nif (!isValidProvider(opts.provider)) {\n  throw new Error(`Unsupported provider: ${opts.provider}. Allowed: ${[...ALLOWED_PROVIDERS].join(', ')}`);\n}\nawait runCopyEditBatchAgent(batch, opts);","typeGuard":"/** Narrow an unknown value to a supported copy-edit provider name. */\nfunction isCopyEditProvider(value) {\n  return typeof value === 'string'\n    && ['mock', 'chat', 'codex', 'claude'].includes(value);\n}","tryCatchPattern":"try {\n  await runCopyEditBatchAgent(batch, { provider });\n} catch (err) {\n  if (/Unsupported live copy-edit AI runner/.test(err.message)) {\n    // provider dispatch table is out of sync — leave opts.provider unset\n    // and let chooseCopyEditAgent pick a valid one.\n    await runCopyEditBatchAgent(batch, {});\n    return;\n  }\n  throw err;\n}","preventionTips":["Leave opts.provider undefined in production so chooseCopyEditAgent() is the single source of valid values.","If you forward a user-configured provider string, validate against the allowlist before calling.","When adding a provider, update chooseCopyEditAgent(), this if/else, and the allowlist together."],"tags":["live-copy-edit","agent-runner","defensive-guard"],"backgroundTag":null,"analyzedSha":"d14711ae3d1a1dd62dee61a358d27f107c51ccd0","analyzedAt":"2026-08-13T00:52:25.771Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}