codeep 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/acp/commands.d.ts +15 -0
  2. package/dist/acp/commands.js +39 -5
  3. package/dist/acp/server.d.ts +13 -0
  4. package/dist/acp/server.js +283 -27
  5. package/dist/acp/serverHandlers.js +10 -10
  6. package/dist/acp/session.d.ts +13 -2
  7. package/dist/acp/transport.d.ts +6 -0
  8. package/dist/acp/transport.js +98 -3
  9. package/dist/api/index.js +6 -3
  10. package/dist/config/index.js +12 -4
  11. package/dist/config/providers.d.ts +48 -4
  12. package/dist/config/providers.js +325 -88
  13. package/dist/renderer/agentExecution.js +116 -69
  14. package/dist/renderer/commands.js +36 -11
  15. package/dist/renderer/main.d.ts +24 -0
  16. package/dist/renderer/main.js +57 -2
  17. package/dist/utils/agent.d.ts +33 -2
  18. package/dist/utils/agent.js +86 -8
  19. package/dist/utils/agentChat.js +22 -10
  20. package/dist/utils/checkpoints.js +3 -0
  21. package/dist/utils/codeReview.js +28 -23
  22. package/dist/utils/git.d.ts +262 -4
  23. package/dist/utils/git.js +1928 -61
  24. package/dist/utils/gitHookInstaller.d.ts +32 -1
  25. package/dist/utils/gitHookInstaller.js +76 -8
  26. package/dist/utils/headlessReview.js +26 -5
  27. package/dist/utils/personalities.js +8 -2
  28. package/dist/utils/shell.d.ts +108 -0
  29. package/dist/utils/shell.js +364 -5
  30. package/dist/utils/taskPlanner.js +12 -4
  31. package/dist/utils/telegramApproval.d.ts +10 -2
  32. package/dist/utils/telegramApproval.js +22 -4
  33. package/dist/utils/tokenTracker.d.ts +13 -5
  34. package/dist/utils/tokenTracker.js +163 -34
  35. package/dist/utils/toolExecution.d.ts +41 -0
  36. package/dist/utils/toolExecution.js +357 -1
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/package.json +1 -1
@@ -14,7 +14,38 @@ export declare function parseHookArgs(argv: string[]): HookArgs;
14
14
  export declare function buildHookScript(hookType: HookType, failOn: FailOn): string;
15
15
  /** True when a hook file was created by Codeep (safe to overwrite/remove). */
16
16
  export declare function isCodeepHook(content: string): boolean;
17
- /** Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if not a repo. */
17
+ /**
18
+ * Where this repository keeps its hooks — as a three-way answer, because two
19
+ * of the three used to come back as the same `null`.
20
+ *
21
+ * `none` means git answered and there is no hook directory to speak of (not a
22
+ * repository, no git on PATH). `unknown` means git was REFUSED: hardenedGitEnv
23
+ * would not build an environment for this repository, so nobody can say where
24
+ * its hooks live or whether a path is one.
25
+ *
26
+ * Folding `unknown` into `none` is a fail-OPEN, and it is a live hole rather
27
+ * than a theoretical one: the write gate in utils/toolExecution.ts reads a
28
+ * null hook directory as "this repository has no hook directory" and stops
29
+ * gating, so the one repository whose config Codeep refuses to scan is
30
+ * exactly the one whose `.githooks/pre-commit` an agent could write
31
+ * unprompted. A caller that has to decide something must branch on `kind`.
32
+ */
33
+ export type HooksDirResult = {
34
+ kind: 'hooks';
35
+ dir: string;
36
+ } | {
37
+ kind: 'none';
38
+ } | {
39
+ kind: 'unknown';
40
+ reason: string;
41
+ };
42
+ export declare function resolveHooksDirResult(cwd: string): HooksDirResult;
43
+ /**
44
+ * Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if
45
+ * not a repo — and THROWS `GitHardeningError` when git was refused, so a
46
+ * refusal can never be mistaken for "no hooks here". Callers that must not
47
+ * throw (the write gate) use resolveHooksDirResult() above instead.
48
+ */
18
49
  export declare function resolveHooksDir(cwd: string): string | null;
19
50
  export interface HookDeps {
20
51
  resolveHooksDir: (cwd: string) => string | null;
@@ -7,6 +7,7 @@
7
7
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'fs';
8
8
  import { join, dirname, isAbsolute } from 'path';
9
9
  import { execSync } from 'child_process';
10
+ import { hardenedGitEnv, GitHardeningError } from './git.js';
10
11
  const FAIL_ON_VALUES = ['error', 'warning', 'info', 'none'];
11
12
  const MARKER_START = '# >>> codeep hook >>>';
12
13
  const MARKER_END = '# <<< codeep hook <<<';
@@ -91,16 +92,71 @@ export function buildHookScript(hookType, failOn) {
91
92
  export function isCodeepHook(content) {
92
93
  return content.includes(MARKER_START);
93
94
  }
94
- /** Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if not a repo. */
95
- export function resolveHooksDir(cwd) {
95
+ export function resolveHooksDirResult(cwd) {
96
+ let env;
96
97
  try {
97
- execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore' });
98
- const hooks = execSync('git rev-parse --git-path hooks', { cwd, encoding: 'utf8' }).trim();
99
- return isAbsolute(hooks) ? hooks : join(cwd, hooks);
98
+ // noHooks stays off: answering where the hooks live is this function's
99
+ // entire job, and an override would have git report the no-hooks path and
100
+ // send `codeep hook install` there.
101
+ env = hardenedGitEnv({ cwd });
102
+ }
103
+ catch (error) {
104
+ if (error instanceof GitHardeningError)
105
+ return { kind: 'unknown', reason: error.message };
106
+ throw error;
100
107
  }
101
- catch {
102
- return null;
108
+ try {
109
+ // stderr is piped rather than ignored so the catch below can tell git's
110
+ // own answer apart from everything else. Ignoring it is what made the two
111
+ // indistinguishable in the first place.
112
+ execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: ['ignore', 'ignore', 'pipe'], env });
113
+ const hooks = execSync('git rev-parse --git-path hooks', {
114
+ cwd,
115
+ encoding: 'utf8',
116
+ stdio: ['ignore', 'pipe', 'pipe'],
117
+ env,
118
+ }).trim();
119
+ return { kind: 'hooks', dir: isAbsolute(hooks) ? hooks : join(cwd, hooks) };
103
120
  }
121
+ catch (error) {
122
+ // Only git's own "there is no repository here" is `none`. The bare
123
+ // `catch { kind: 'none' }` this replaces folded a timeout, an EACCES on
124
+ // `cwd` and a git that died mid-answer into the same result — and `none`
125
+ // is what the write gate in utils/toolExecution.ts reads as "this
126
+ // repository has no hook directory, stop gating", so every one of those
127
+ // failures turned the gate off. `unknown` keeps it on, which is the only
128
+ // safe way round to be wrong.
129
+ const err = error;
130
+ const stderr = String(err.stderr ?? '');
131
+ // git's own answer for "there is no repository here". A BARE repository
132
+ // is deliberately not in this branch: `--is-inside-work-tree` prints
133
+ // `false` and exits 0 there (verified, git 2.54), so it comes back as a
134
+ // hooks directory, which is what it has.
135
+ if (/not a git repository/i.test(stderr))
136
+ return { kind: 'none' };
137
+ // git never started: not on PATH, or `cwd` is gone. The caller's own git
138
+ // calls cannot run either, so there is no hook directory to speak of.
139
+ if (err.code === 'ENOENT' || err.code === 'EACCES' || err.code === 'ENOTDIR')
140
+ return { kind: 'none' };
141
+ const detail = stderr.trim() || err.code || `exit ${err.status ?? '?'}`;
142
+ return {
143
+ kind: 'unknown',
144
+ reason: `Cannot tell where ${cwd} keeps its git hooks: git failed to answer (${detail}). ` +
145
+ 'Codeep treats that as "there may be hooks here" rather than as "there are none".',
146
+ };
147
+ }
148
+ }
149
+ /**
150
+ * Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if
151
+ * not a repo — and THROWS `GitHardeningError` when git was refused, so a
152
+ * refusal can never be mistaken for "no hooks here". Callers that must not
153
+ * throw (the write gate) use resolveHooksDirResult() above instead.
154
+ */
155
+ export function resolveHooksDir(cwd) {
156
+ const result = resolveHooksDirResult(cwd);
157
+ if (result.kind === 'unknown')
158
+ throw new GitHardeningError(result.reason);
159
+ return result.kind === 'hooks' ? result.dir : null;
104
160
  }
105
161
  export function runHookCommand(argv, deps = defaultHookDeps()) {
106
162
  const args = parseHookArgs(argv);
@@ -108,7 +164,19 @@ export function runHookCommand(argv, deps = defaultHookDeps()) {
108
164
  deps.write(HOOK_HELP);
109
165
  return 0;
110
166
  }
111
- const hooksDir = deps.resolveHooksDir(process.cwd());
167
+ // `resolveHooksDir` throws rather than answer null when git was refused, so
168
+ // the two failures get the two different messages they need: "you are not in
169
+ // a repo" is not a useful thing to tell someone whose `.git/config` names a
170
+ // program Codeep will not run through.
171
+ let hooksDir;
172
+ try {
173
+ hooksDir = deps.resolveHooksDir(process.cwd());
174
+ }
175
+ catch (error) {
176
+ deps.write(`${error instanceof Error ? error.message : String(error)}\n` +
177
+ 'Fix that config (or run the hook installer yourself) and try again.');
178
+ return 1;
179
+ }
112
180
  if (!hooksDir) {
113
181
  deps.write('Not a git repository — run `codeep hook` inside a repo.');
114
182
  return 1;
@@ -227,6 +227,7 @@ export async function runFixPlan(plan, context) {
227
227
  return `${summariseFixPlan(plan)} No API key is configured for the current provider, so the fix agent could not start.`;
228
228
  }
229
229
  const { runAgent } = await import('./agent.js');
230
+ const { NO_CONFIRMER_REFUSAL } = await import('./toolExecution.js');
230
231
  // No cast here. `as never` on this call once hid the fact that
231
232
  // personalityOverride did not exist, which would have run the CI fix with
232
233
  // no boundary at all while the tests happily asserted otherwise.
@@ -239,24 +240,44 @@ export async function runFixPlan(plan, context) {
239
240
  // the plan, which buildFixPlan caps, and the action's wall-clock.
240
241
  maxIterations: 25,
241
242
  });
243
+ // `result === 'success'` matters: a write the agent ATTEMPTED is logged
244
+ // whether or not it happened, so a refused `.git/config` was being
245
+ // reported to CI as a file this run edited — the exact opposite of the
246
+ // truth, and the file is not in the diff for anyone to check against.
242
247
  const edited = new Set(result.actions
243
- .filter(a => a.type === 'write' || a.type === 'edit')
248
+ .filter(a => (a.type === 'write' || a.type === 'edit') && a.result === 'success')
244
249
  .map(a => a.target));
245
250
  const activity = describeAgentActivity(result.actions);
251
+ // A headless run passes no permission callback, so a write to a file that
252
+ // decides what runs later — `.git/config`, a hook, an MCP server list —
253
+ // is refused rather than done unasked. That is the right answer for CI:
254
+ // an unattended run must not be the thing that installs a hook. But the
255
+ // refusal only ever reached the model, so the run looked like it had
256
+ // simply chosen not to fix that finding, and the one action a human has
257
+ // to take was in nobody's log. Name it in the summary, which is what the
258
+ // action prints. An opt-in flag was the alternative and is worse: it
259
+ // would exist to be set in a YAML file once and then never read again.
260
+ const refused = [...new Set(result.actions
261
+ .filter(a => a.result === 'error' && a.details?.includes(NO_CONFIRMER_REFUSAL))
262
+ .map(a => a.target))];
263
+ const refusalNote = refused.length
264
+ ? ` It was refused ${refused.length} write${refused.length === 1 ? '' : 's'} to ${refused.join(', ')}: ` +
265
+ 'those files decide what runs later, and a headless run has nobody to confirm them. Edit them yourself.'
266
+ : '';
246
267
  const editedList = `Edited ${edited.size} file${edited.size === 1 ? '' : 's'}: ${[...edited].join(', ')}.`;
247
268
  if (!result.success) {
248
269
  // The run did its work and the checks after it failed. The edits are in
249
270
  // the working tree all the same, so name them next to the checks.
250
271
  if (result.failedChecks?.length) {
251
272
  const changed = edited.size > 0 ? editedList : activity;
252
- return `${summariseFixPlan(plan)} ${changed} These checks still fail afterwards: ${result.failedChecks.join(', ')}.`;
273
+ return `${summariseFixPlan(plan)} ${changed}${refusalNote} These checks still fail afterwards: ${result.failedChecks.join(', ')}.`;
253
274
  }
254
- return `${summariseFixPlan(plan)} The run did not finish: ${result.error ?? 'unknown error'}. ${activity}`;
275
+ return `${summariseFixPlan(plan)} The run did not finish: ${result.error ?? 'unknown error'}. ${activity}${refusalNote}`;
255
276
  }
256
277
  if (edited.size === 0) {
257
- return `${summariseFixPlan(plan)} Nothing was changed. ${activity}`;
278
+ return `${summariseFixPlan(plan)} Nothing was changed. ${activity}${refusalNote}`;
258
279
  }
259
- return `${summariseFixPlan(plan)} ${editedList}`;
280
+ return `${summariseFixPlan(plan)} ${editedList}${refusalNote}`;
260
281
  }
261
282
  catch (error) {
262
283
  // A missing key or an unreachable provider must not fail the review. The
@@ -39,7 +39,7 @@ import { basename, join } from 'path';
39
39
  import { homedir } from 'os';
40
40
  import { leadsOutsideProject } from './projectPaths.js';
41
41
  import { config } from '../config/index.js';
42
- import { getProvider } from '../config/providers.js';
42
+ import { getProvider, replacementModelFor } from '../config/providers.js';
43
43
  const CAPABILITIES = new Set([
44
44
  'files', 'terminal', 'tests', 'git', 'web', 'mcp',
45
45
  ]);
@@ -126,7 +126,13 @@ function exactModelPreference(preference) {
126
126
  return null;
127
127
  const providerId = value.slice(0, slash).trim();
128
128
  const model = value.slice(slash + 1).trim();
129
- return providerId && model ? { providerId, model } : null;
129
+ if (!providerId || !model)
130
+ return null;
131
+ // A bot written before a vendor retired its model (or before Codeep stopped
132
+ // offering one, like `openai/gpt-6-astra`) names an id the picker no longer
133
+ // has, and the exact check below would make the whole bot unavailable. Map it
134
+ // the way the startup migration and applyProfile map a stored id.
135
+ return { providerId, model: replacementModelFor(providerId, model) ?? model };
130
136
  }
131
137
  /** Whether a structured bot's model field satisfies the portable v1 contract. */
132
138
  export function isPersonalityModelPreferenceValid(personality) {
@@ -35,6 +35,114 @@ export declare function validateCommand(command: string, args: string[], options
35
35
  valid: boolean;
36
36
  reason?: string;
37
37
  };
38
+ /**
39
+ * The environment a validated command runs in.
40
+ *
41
+ * `git` is on ALLOWED_COMMANDS, so a skill's shell line, a `!` command or the
42
+ * agent's own execute_command reaches git with whatever the repository put in
43
+ * its `.git/config` — and several of those settings make git RUN a program:
44
+ * a `filter.<driver>.clean` fires during the index refresh `git status` does,
45
+ * before anything looks like it executed code. Route git through the same
46
+ * hardening Codeep's own git calls use.
47
+ *
48
+ * Hooks are deliberately left alone here. The command was approved as
49
+ * written, so `git commit` through this path runs the repository's
50
+ * pre-commit hook exactly as it would in the user's terminal.
51
+ *
52
+ * A caller's own `env` goes in as the BASE rather than on top of the result:
53
+ * spread afterwards, their GIT_CONFIG_COUNT would replace ours and silently
54
+ * drop every override above their count.
55
+ *
56
+ * The bare name is the whole test because it has to be: validateCommand()
57
+ * only lets a command through when ALLOWED_COMMANDS holds it, and that set
58
+ * holds `git`, not `/usr/bin/git`. A path-spelled git never reaches here.
59
+ *
60
+ * The directory scanned comes from the ARGV, not from the spawn's cwd: `git
61
+ * -C vendor/lib status` reads the vendored checkout's config, so that is the
62
+ * config that has to be neutralised. The argv forms that redirect git
63
+ * somewhere this cannot follow (`--git-dir`, `--work-tree`, `--exec-path`,
64
+ * `--config-env`) never get here — validateCommand() refuses them.
65
+ *
66
+ * Throws `GitHardeningError` when the repository's config cannot be scanned —
67
+ * both runners below turn that into a failed CommandResult, because a refusal
68
+ * is this command's own failure and the user reads it as such.
69
+ *
70
+ * EXPORTED, and this signature is the contract, because the ACP terminal
71
+ * path spawns its own children and has to harden the SAME repository this
72
+ * does. Call it with the parsed command, its argv, the cwd the spawn will
73
+ * get and the caller's own env in `options.env`, and hand the result to the
74
+ * spawn as `env` — do not spread anything over it, or a later
75
+ * GIT_CONFIG_COUNT replaces ours and silently drops every override above it.
76
+ * The argv is not optional there: `git -C vendor/lib status` scans
77
+ * `vendor/lib`, and a caller that passes only the cwd hardens the wrong
78
+ * repository. A shell LINE rather than an argv belongs to shellCommandEnv()
79
+ * below instead. Both throw, and a refusal that escapes a promise executor
80
+ * never settles it.
81
+ */
82
+ export declare function commandEnv(command: string, args: string[], cwd: string, options?: CommandOptions): NodeJS.ProcessEnv;
83
+ /**
84
+ * The environment for a whole SHELL COMMAND LINE that may reach git.
85
+ *
86
+ * commandEnv() above can check a parsed binary name; a line handed to a shell
87
+ * can reach git from anywhere inside it — `cd sub && git status`, `make && git
88
+ * commit`, `foo | git apply` — so it needs its own entry point. This is that
89
+ * entry point for the callers that spawn with `shell: true`: the skill runner
90
+ * in src/acp/commands.ts and the one in src/renderer/agentExecution.ts, both
91
+ * of which used to reach git raw. A hostile `gpg.program` that createCommit
92
+ * neutralises still executed through those two spawns (proven, git 2.54).
93
+ *
94
+ * This is the ONE helper for that job — an earlier cut of this hotfix also
95
+ * had a `hardenedShellEnv()` in utils/toolExecution.ts, which hardened every
96
+ * skill step unconditionally and therefore refused an `echo` in a repository
97
+ * whose config cannot be scanned. Keep it one: two helpers with two different
98
+ * answers to "does a refusal stop this line?" is how one of them ends up
99
+ * wrong and unused.
100
+ *
101
+ * The contract, since those two call sites are not this file's to edit:
102
+ *
103
+ * - Pass the command line, the cwd the shell will get and any env of your
104
+ * own, and hand the RESULT to the spawn as `env`. Do not spread anything
105
+ * over it — a later `GIT_CONFIG_COUNT` replaces ours and silently drops
106
+ * every override above it.
107
+ * - It THROWS `GitHardeningError` when the repository's config cannot be
108
+ * scanned, or names a program no override can switch off. Catch it and fail
109
+ * the command with `error.message`, which is written for the user. Letting
110
+ * it escape a `spawnSync` call site turns a refusal into a crash; letting
111
+ * it escape inside a promise executor leaves the caller hanging.
112
+ * - Hooks are left alone, as they are for executeCommand(): the line was
113
+ * approved as written, so `git commit` in it runs the repository's
114
+ * pre-commit hook exactly as it would in the user's terminal.
115
+ * - A line that cannot reach git comes back unhardened, so a repository with
116
+ * an unreadable config does not also break `echo`. That is also why a
117
+ * refusal never reaches a non-git line: an `echo` must not stop working
118
+ * because some repository in the project sets `remote.origin.uploadpack`.
119
+ *
120
+ * WHAT THIS CAN AND CANNOT PROMISE, because a shell line is not an argv:
121
+ *
122
+ * - Scanned: the repository at `cwd`, AND every submodule of it — the ones
123
+ * its index records as gitlinks and the ones its config records by name,
124
+ * wherever each keeps its git directory (see listSubmoduleConfig in
125
+ * utils/git.ts). Every key in REPO_EXECUTING_RULES
126
+ * that any of them sets is neutralised, and because the overrides ride in
127
+ * the ENVIRONMENT rather than in an argv, they apply wherever in the line
128
+ * git ends up — so `cd vendor/lib && git add` is covered in full when
129
+ * `vendor/lib` is a submodule, which is the shape a skill step usually has.
130
+ * - Not scanned: a repository that is not `cwd` and not one of its
131
+ * submodules — an independent checkout under `vendor/`, a sibling clone,
132
+ * anywhere a `make` target cds to. There is no way to know where a shell
133
+ * line ends up without running it, so this does not pretend to. What still
134
+ * covers those is the always-on GIT_EXECUTING_CONFIG layer, which is why
135
+ * `core.fsmonitor` is blanket there rather than scope-aware. The gap is the
136
+ * keys GIT_CONFIG_* cannot wildcard — `filter.*` above all — in an
137
+ * unrelated repository below the one scanned. Proven with git 2.54: `cd
138
+ * vendor/lib && git status`, with `vendor/lib` a plain nested clone rather
139
+ * than a submodule, did not run the nested `core.fsmonitor` and did run the
140
+ * nested `filter.<d>.clean`.
141
+ * - executeCommand()'s argv path has no such gap: it reads `-C` out of the
142
+ * argv and scans where git will actually run, and refuses `--git-dir` /
143
+ * `--work-tree` / `--exec-path` / `--config-env` outright.
144
+ */
145
+ export declare function shellCommandEnv(commandLine: string, cwd: string, env?: Record<string, string>): NodeJS.ProcessEnv;
38
146
  /**
39
147
  * Execute a shell command with safety checks
40
148
  */