index.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import type { PluginInput } from '@opencode-ai/plugin';
  2. import { log } from '../../utils/logger';
  3. import {
  4. createApplyPatchInternalError,
  5. getApplyPatchErrorDetails,
  6. isApplyPatchError,
  7. isApplyPatchVerificationError,
  8. } from './errors';
  9. import { rewritePatch } from './operations';
  10. import type { ApplyPatchRuntimeOptions } from './types';
  11. const APPLY_PATCH_RESCUE_OPTIONS: ApplyPatchRuntimeOptions = {
  12. prefixSuffix: true,
  13. lcsRescue: true,
  14. };
  15. interface ToolExecuteBeforeInput {
  16. tool: string;
  17. directory?: string;
  18. }
  19. interface ToolExecuteBeforeOutput {
  20. args?: {
  21. patchText?: unknown;
  22. [key: string]: unknown;
  23. };
  24. }
  25. function replacePatchArgs(
  26. output: ToolExecuteBeforeOutput,
  27. args: NonNullable<ToolExecuteBeforeOutput['args']>,
  28. patchText: string,
  29. ): boolean {
  30. const nextArgs = { ...args, patchText };
  31. try {
  32. output.args = nextArgs;
  33. } catch {
  34. return false;
  35. }
  36. return output.args?.patchText === patchText;
  37. }
  38. export function createApplyPatchHook(ctx: PluginInput) {
  39. function logHookStatus(
  40. state:
  41. | 'rewrite'
  42. | 'unchanged'
  43. | 'skipped'
  44. | 'blocked'
  45. | 'validation'
  46. | 'verification'
  47. | 'internal',
  48. data?: Record<string, unknown>,
  49. ) {
  50. log(`apply-patch hook ${state}`, data);
  51. }
  52. return {
  53. 'tool.execute.before': async (
  54. input: ToolExecuteBeforeInput,
  55. output: ToolExecuteBeforeOutput,
  56. ): Promise<void> => {
  57. if (input.tool !== 'apply_patch') {
  58. return;
  59. }
  60. const args = output.args;
  61. if (!args || typeof args.patchText !== 'string') {
  62. return;
  63. }
  64. const patchText = args.patchText;
  65. const root = input.directory || ctx.directory || process.cwd();
  66. const worktree = ctx.worktree || root;
  67. try {
  68. const result = await rewritePatch(
  69. root,
  70. patchText,
  71. APPLY_PATCH_RESCUE_OPTIONS,
  72. worktree,
  73. );
  74. if (result.changed) {
  75. if (replacePatchArgs(output, args, result.patchText)) {
  76. logHookStatus('rewrite');
  77. } else {
  78. logHookStatus('skipped', {
  79. reason: 'readonly output args',
  80. failOpen: true,
  81. rescueOptions: APPLY_PATCH_RESCUE_OPTIONS,
  82. rewriteStage: 'before-native',
  83. });
  84. }
  85. return;
  86. }
  87. logHookStatus('unchanged');
  88. return;
  89. } catch (error) {
  90. const normalizedError = isApplyPatchError(error)
  91. ? error
  92. : createApplyPatchInternalError(
  93. `Unexpected hook failure before native apply: ${error instanceof Error ? error.message : String(error)}`,
  94. error,
  95. );
  96. const details = getApplyPatchErrorDetails(normalizedError);
  97. if (
  98. normalizedError.kind === 'blocked' &&
  99. // Only the plugin-side outside-workspace preflight should fail open.
  100. // Keep the code check explicit so any future blocked error remains
  101. // fail-closed by default.
  102. details?.code === 'outside_workspace'
  103. ) {
  104. logHookStatus('skipped', {
  105. kind: details.kind,
  106. code: details.code,
  107. reason: normalizedError.message,
  108. failOpen: true,
  109. rescueOptions: APPLY_PATCH_RESCUE_OPTIONS,
  110. rewriteStage: 'before-native',
  111. });
  112. return;
  113. }
  114. logHookStatus(
  115. isApplyPatchVerificationError(normalizedError)
  116. ? 'verification'
  117. : normalizedError.kind === 'validation'
  118. ? 'validation'
  119. : normalizedError.kind === 'internal'
  120. ? 'internal'
  121. : 'blocked',
  122. {
  123. kind: details?.kind ?? 'internal',
  124. code: details?.code ?? 'internal_unexpected',
  125. reason: normalizedError.message,
  126. failOpen: false,
  127. rescueOptions: APPLY_PATCH_RESCUE_OPTIONS,
  128. rewriteStage: 'before-native',
  129. },
  130. );
  131. throw normalizedError;
  132. }
  133. },
  134. };
  135. }