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
@@ -28,6 +28,21 @@ export interface AcpSession {
28
28
  export type AcpAgentRunOptions = Pick<AgentSessionOptions, 'onRequestPermission' | 'extraDangerousTools' | 'onExecuteCommand' | 'fs'> & {
29
29
  /** Ask the user a yes/no question. Unset: the mode runs without asking. */
30
30
  confirm?: (message: string) => Promise<boolean>;
31
+ /**
32
+ * Auto mode's answer to the agent's permission gate: yes to everything
33
+ * except a write to a file that decides what runs later, which is asked
34
+ * about in every mode. A command that runs the agent passes it in place of
35
+ * the missing `onRequestPermission`, because the agent refuses those writes
36
+ * outright when it has nobody to ask — so without this, /go and a skill's
37
+ * agent step could not touch `.git/config` at all in auto mode, while a
38
+ * plain prompt could after a confirmation.
39
+ *
40
+ * Kept under its own key rather than set on `onRequestPermission`, which
41
+ * this file reads as "this session asks the user" when it decides whether
42
+ * to gate a skill's shell lines. Auto mode runs those without asking, as
43
+ * that mode promises.
44
+ */
45
+ onAutoModePermission?: AgentSessionOptions['onRequestPermission'];
31
46
  };
32
47
  export interface CommandResult {
33
48
  /** true if the input was a slash command (even if it failed) */
@@ -4,7 +4,7 @@
4
4
  // responses (no TUI) suitable for streaming back via session/update.
5
5
  import { config, getCurrentProvider, getModelsForCurrentProvider, setProvider, setApiKey, isConfigured, listSessionsWithInfo, startNewSession, loadSession, saveSession, initializeAsProject, isManuallyInitializedProject, setProjectPermission, hasWritePermission, hasReadPermission, sessionNameProblem, sessionNameTaken, } from '../config/index.js';
6
6
  import { symlinkedCodeepNotice } from '../utils/projectPaths.js';
7
- import { getProviderList, getProvider } from '../config/providers.js';
7
+ import { getProviderList, getProvider, replacementModelFor } from '../config/providers.js';
8
8
  import { telemetryCommand } from '../commands/core/telemetry.js';
9
9
  import { keysyncCommand } from '../commands/core/keysync.js';
10
10
  import { getProjectContext } from '../utils/project.js';
@@ -15,6 +15,8 @@ import { existsSync, mkdirSync } from 'fs';
15
15
  import { join } from 'path';
16
16
  import { chat } from '../api/index.js';
17
17
  import { runAgent, classifyPermissionOutcome, buildDangerousTools } from '../utils/agent.js';
18
+ import { forgetHooksDirectory } from '../utils/toolExecution.js';
19
+ import { shellCommandEnv } from '../utils/shell.js';
18
20
  import { beginTurn } from './turns.js';
19
21
  /** Pending plans a /go is executing right now. */
20
22
  const plansRunning = new Set();
@@ -1385,20 +1387,25 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1385
1387
  saveSession(session.codeepSessionId, session.history, session.workspaceRoot);
1386
1388
  // If the checkpoint captured a different provider/model, switch back.
1387
1389
  // configOptionsChanged signals the client to refresh its dropdowns.
1390
+ // A checkpoint predates any later retirement, so its model goes through
1391
+ // the same map as a stored config (`gpt-6-astra` comes back as
1392
+ // `gpt-6-sol`), looked up on the provider actually active after the switch.
1388
1393
  let providerChanged = false;
1389
1394
  if (cp.provider && cp.provider !== getCurrentProvider().id) {
1390
1395
  setProvider(cp.provider);
1391
1396
  providerChanged = true;
1392
1397
  }
1393
- if (cp.model && cp.model !== config.get('model')) {
1394
- config.set('model', cp.model);
1398
+ const cpModel = cp.model && (replacementModelFor(config.get('provider'), cp.model) ?? cp.model);
1399
+ if (cpModel && cpModel !== config.get('model')) {
1400
+ config.set('model', cpModel);
1395
1401
  providerChanged = true;
1396
1402
  }
1403
+ const movedNote = cpModel !== cp.model ? ` (the checkpoint's \`${cp.model}\` is no longer offered)` : '';
1397
1404
  const lines = [
1398
1405
  `## Rewound to ${cp.name ? `**${cp.name}**` : `\`${cp.id}\``}`,
1399
1406
  '',
1400
1407
  `Restored ${cp.messages.length} message${cp.messages.length === 1 ? '' : 's'} (was ${replacedCount}).`,
1401
- cp.provider && cp.model ? `Provider: \`${cp.provider}\` · Model: \`${cp.model}\`` : '',
1408
+ cp.provider && cpModel ? `Provider: \`${cp.provider}\` · Model: \`${cpModel}\`${movedNote}` : '',
1402
1409
  '',
1403
1410
  buildRewindGitHint(cp),
1404
1411
  ].filter(Boolean);
@@ -1535,12 +1542,35 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1535
1542
  stopIfCancelled();
1536
1543
  }
1537
1544
  }
1545
+ // A raw process.env here handed the repository's own `.git/config`
1546
+ // back to git: `/commit` runs `git commit`, and a repo-scope
1547
+ // `gpg.program` that Codeep's own commit path neutralises executed
1548
+ // through this spawn instead.
1549
+ //
1550
+ // Built before the spawn, and caught: shellCommandEnv() refuses a
1551
+ // git line in a repository whose config names a program no override
1552
+ // switches off, and a refusal that escaped here would abort the
1553
+ // whole skill from inside executeSkill's callback. Failing the step
1554
+ // with the refusal's own wording is what a step that cannot run
1555
+ // looks like everywhere else in this handler.
1556
+ let env;
1557
+ try {
1558
+ env = shellCommandEnv(shellCmd, session.workspaceRoot);
1559
+ }
1560
+ catch (error) {
1561
+ throw new Error(`\`${shellCmd}\` was not run: ${error instanceof Error ? error.message : String(error)}`);
1562
+ }
1563
+ // A command line is the one thing in a skill that can move this
1564
+ // repository's hooks (`git config core.hooksPath .evil`), and the
1565
+ // write gate caches where they are for the run.
1566
+ forgetHooksDirectory();
1538
1567
  const proc = spawnSync(shellCmd, {
1539
1568
  cwd: session.workspaceRoot,
1540
1569
  encoding: 'utf-8',
1541
1570
  timeout: 60_000,
1542
1571
  shell: true,
1543
1572
  stdio: ['pipe', 'pipe', 'pipe'],
1573
+ env,
1544
1574
  });
1545
1575
  const out = ((proc.stdout || '') + (proc.stderr || '')).trim();
1546
1576
  const block = `\`${shellCmd}\`\n\`\`\`\n${out || '(no output)'}\n\`\`\`\n`;
@@ -1603,7 +1633,11 @@ async function runCommandAgent(task, session, onChunk, abortSignal, agentRun) {
1603
1633
  abortSignal,
1604
1634
  onIteration: (_i, msg) => { onChunk(msg + '\n'); },
1605
1635
  onThinking: (text) => { onChunk(text); },
1606
- onRequestPermission: agentRun?.onRequestPermission,
1636
+ // Manual mode's dialog, or — in auto mode, where there is none — the
1637
+ // answer that mode gives. Passing nothing would leave the run with no way
1638
+ // to confirm a write to a file that decides what runs later, and the agent
1639
+ // refuses those rather than doing them unasked.
1640
+ onRequestPermission: agentRun?.onRequestPermission ?? agentRun?.onAutoModePermission,
1607
1641
  extraDangerousTools: agentRun?.extraDangerousTools,
1608
1642
  onExecuteCommand: agentRun?.onExecuteCommand,
1609
1643
  fs: agentRun?.fs,
@@ -79,4 +79,17 @@ export declare function exitCodeFromWaitResult(result: unknown): number | null;
79
79
  * Exported for unit testing (see server.command.test.ts).
80
80
  */
81
81
  export declare function executeAcpCommand(command: string, args: string[], cwd: string, ctx: AcpCommandContext): Promise<AcpCommandOutcome>;
82
+ /**
83
+ * What to tell the user when a prompt fails on authentication, or null when
84
+ * the failure is about something else.
85
+ *
86
+ * Every 401 used to read "No API key configured", key or no key. Kimi Code
87
+ * answers 401 for its plan limits — no K3 on the plan, K3 past 256K on
88
+ * Plus/Moderato, High-Speed below Pro/Allegretto, an unknown model id
89
+ * (kimi.com/code/docs/en/kimi-code/error-reference.html) — so a subscriber with
90
+ * a perfectly good key was sent to /login when the fix was another model. With
91
+ * a key configured, a 401 now says what else it can mean and quotes the
92
+ * provider.
93
+ */
94
+ export declare function authFailureNotice(err: Error, providerId: string, keyConfigured: boolean): string | null;
82
95
  export declare function startAcpServer(transport?: StdioTransport): Promise<void>;
@@ -10,9 +10,10 @@ import { loadCustomCommands } from '../utils/customCommands.js';
10
10
  import { registerSessionServers, disposeAllSessions as disposeAllMcpSessions } from '../utils/mcpRegistry.js';
11
11
  import { selectSessionMcpServers } from '../utils/mcpConfig.js';
12
12
  import { handleMcpSamplingRequest } from '../utils/mcpSamplingBridge.js';
13
- import { executeCommandAsync, validateCommandAsync } from '../utils/shell.js';
13
+ import { executeCommandAsync, validateCommandAsync, commandEnv } from '../utils/shell.js';
14
14
  import { checkCommandRateLimit } from '../utils/ratelimit.js';
15
15
  import { recordCommand } from '../utils/history.js';
16
+ import { trustBearingWrite } from '../utils/toolExecution.js';
16
17
  import { initWorkspace, loadWorkspace, handleCommand } from './commands.js';
17
18
  import { beginTurn } from './turns.js';
18
19
  import { handleSetMode as handleSetModeExternal, handleSetConfigOption as handleSetConfigOptionExternal, handleSessionList as handleSessionListExternal, handleSessionDelete as handleSessionDeleteExternal, handleListProviders as handleListProvidersExternal, } from './serverHandlers.js';
@@ -421,6 +422,158 @@ export function exitCodeFromWaitResult(result) {
421
422
  return 1;
422
423
  return null;
423
424
  }
425
+ /**
426
+ * `GIT_CONFIG_COUNT` and the `GIT_CONFIG_KEY_<n>` / `GIT_CONFIG_VALUE_<n>`
427
+ * pairs it counts — the numbered half of what hardenedGitEnv() produces.
428
+ */
429
+ const GIT_CONFIG_ENV_NAME = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/;
430
+ /**
431
+ * The rest of what the terminal has to be given by name — see acpEnvList().
432
+ *
433
+ * `GIT_PAGER` and `GIT_TERMINAL_PROMPT` are hardenedGitEnv()'s two
434
+ * non-numbered outputs. `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` /
435
+ * `GIT_CONFIG_NOSYSTEM` are the variables the SCAN itself read the config
436
+ * through: leaving them behind would let the terminal's git resolve a
437
+ * different global config than the one Codeep just decided was safe.
438
+ *
439
+ * Everything after them is there for a client that REPLACES its environment
440
+ * with this list rather than extending it, and it is the half the first cut
441
+ * of this hotfix got wrong: the list was `PATH` and `HOME` — enough for the
442
+ * hardened `git status` it was written for — while acpEnvList() is applied
443
+ * to EVERY command handed to terminal/create. Against a replacing client
444
+ * that cost an ordinary command things that are not secrets and that it had
445
+ * in the user's own shell: `git push` over SSH had no agent socket to sign
446
+ * with and fell back to asking for a password on a terminal whose
447
+ * GIT_TERMINAL_PROMPT is `0`, which fails it outright; a test that sorts
448
+ * strings or formats a date ran under the C locale instead of the user's; a
449
+ * build had nowhere but the default /tmp to put its temporaries; anything
450
+ * behind a corporate proxy could not reach the network at all; and a
451
+ * toolchain installed under a version manager lost the variable its shim
452
+ * reads to pick a version.
453
+ *
454
+ * It stays an ALLOWLIST rather than becoming "process.env minus the names
455
+ * that look like credentials", because what made the change necessary is
456
+ * that `env` is not a private channel (see acpEnvList) and a name-shaped
457
+ * denylist does not recognise `DATABASE_URL`, a company's own `ACME_CREDS`,
458
+ * or anything else whose name does not say what it holds. A name gets in
459
+ * here only when it is known not to hold one.
460
+ *
461
+ * The one value below that CAN carry a credential is a proxy URL
462
+ * (`https_proxy=http://user:pass@proxy`), which is why redactCredentials()
463
+ * in src/acp/transport.ts has a rule for that exact shape. A command behind
464
+ * a proxy cannot reach the network without it.
465
+ */
466
+ const ACP_TERMINAL_ENV_NAMES = new Set([
467
+ // hardenedGitEnv()'s own, and what the scan read the config through.
468
+ 'GIT_PAGER',
469
+ 'GIT_TERMINAL_PROMPT',
470
+ 'GIT_CONFIG_GLOBAL',
471
+ 'GIT_CONFIG_SYSTEM',
472
+ 'GIT_CONFIG_NOSYSTEM',
473
+ // Where programs are found, and whose account runs them. No `PWD`: the
474
+ // terminal's working directory is the `cwd` of the terminal/create, and
475
+ // this process's would tell a shell script it is somewhere it is not.
476
+ 'PATH',
477
+ 'HOME',
478
+ 'SHELL',
479
+ 'USER',
480
+ 'LOGNAME',
481
+ // The agent socket `git push` and `git fetch` over SSH sign with.
482
+ 'SSH_AUTH_SOCK',
483
+ // Where a build puts its temporaries.
484
+ 'TMPDIR',
485
+ 'TMP',
486
+ 'TEMP',
487
+ // The locale a test that sorts strings or formats a date asserts against.
488
+ 'LANG',
489
+ 'LANGUAGE',
490
+ // What the command may draw with, and what it thinks the time is.
491
+ 'TERM',
492
+ 'COLORTERM',
493
+ 'TERM_PROGRAM',
494
+ 'TZ',
495
+ // Toolchains under a version manager: the shim is on PATH, but the shim
496
+ // reads one of these to find the version to run.
497
+ 'ASDF_DIR',
498
+ 'ASDF_DATA_DIR',
499
+ 'NVM_DIR',
500
+ 'NVM_BIN',
501
+ 'PYENV_ROOT',
502
+ 'RBENV_ROOT',
503
+ 'SDKMAN_DIR',
504
+ 'VOLTA_HOME',
505
+ 'PNPM_HOME',
506
+ 'BUN_INSTALL',
507
+ 'VIRTUAL_ENV',
508
+ 'CONDA_PREFIX',
509
+ 'CARGO_HOME',
510
+ 'RUSTUP_HOME',
511
+ 'GOPATH',
512
+ 'GOROOT',
513
+ 'JAVA_HOME',
514
+ ]);
515
+ /**
516
+ * The same allowlist for the two families whose members cannot be listed:
517
+ * the locale categories (`LC_ALL`, `LC_TIME`, `LC_COLLATE`, …), and the
518
+ * proxy variables, which every tool spells in whichever case it was written
519
+ * in — curl and most of Unix read the lowercase ones, Windows-born tools the
520
+ * uppercase, and a machine behind a proxy usually sets both.
521
+ */
522
+ const ACP_TERMINAL_ENV_FAMILY = /^(?:LC_[A-Z]+|(?:HTTP|HTTPS|FTP|ALL|NO)_PROXY|(?:http|https|ftp|all|no)_proxy)$/;
523
+ /**
524
+ * An environment in the shape `terminal/create` takes it: ACP spells it as a
525
+ * list of `{ name, value }`, not as the map Node keeps in `process.env`.
526
+ *
527
+ * An allowlist goes in it — the hardening's own variables and the shell
528
+ * essentials above — and not the WHOLE of `process.env`, which is what this
529
+ * used to serialise. `env` is not a private channel: src/acp/transport.ts
530
+ * mirrors every outbound frame verbatim into ~/.cache/codeep/acp-debug.log
531
+ * when CODEEP_ACP_DEBUG is set, so every `ANTHROPIC_API_KEY`,
532
+ * `GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY` and session cookie in the user's
533
+ * shell was written to a plaintext file on disk — and handed to the editor,
534
+ * which is free to log the protocol traffic itself. None of them makes the
535
+ * hardening work; the numbered GIT_CONFIG_* pairs do.
536
+ *
537
+ * What else the terminal inherits is the CLIENT's decision, not ours: ACP
538
+ * does not say whether `env` extends the client's environment or replaces it.
539
+ * A client that extends gives the command the user's shell environment
540
+ * anyway; a client that replaces gives it only this list — which is why the
541
+ * list has to cover what an ORDINARY command needs to run at all and not
542
+ * only what a hardened git spawn does. A terminal that got `GIT_CONFIG_COUNT`
543
+ * and nothing else would be running without a PATH.
544
+ *
545
+ * Unset variables are dropped rather than sent as `value: undefined` — that
546
+ * is what `process.env` holds for a variable that is not set, and JSON has no
547
+ * way to carry it.
548
+ *
549
+ * `hardened` says whether commandEnv() actually hardened this spawn, which it
550
+ * does for `git` and for nothing else. It only decides the last entry below:
551
+ * a command that cannot reach git is left with the environment it would have
552
+ * had, which is the same line the refusal path draws.
553
+ */
554
+ function acpEnvList(env, hardened) {
555
+ const list = Object.entries(env)
556
+ .filter((entry) => typeof entry[1] === 'string')
557
+ .filter(([name]) => ACP_TERMINAL_ENV_NAMES.has(name)
558
+ || ACP_TERMINAL_ENV_FAMILY.test(name)
559
+ || GIT_CONFIG_ENV_NAME.test(name))
560
+ .map(([name, value]) => ({ name, value }));
561
+ // The one thing this list cannot express is a REMOVAL — an absent name is
562
+ // not a request to unset one — and hardenedGitEnv() removes exactly one
563
+ // variable, so it is sent EMPTY instead.
564
+ //
565
+ // `GIT_CONFIG_PARAMETERS` is read after the numbered `GIT_CONFIG_*` pairs
566
+ // and beats them: with `GIT_CONFIG_KEY_0=core.fsmonitor` and an empty value
567
+ // right there, a `GIT_CONFIG_PARAMETERS='core.fsmonitor=<program>'` still
568
+ // ran the program on `git status` (verified, git 2.54). So against a client
569
+ // that EXTENDS its own environment rather than replacing it, one variable
570
+ // the editor happened to inherit switched this whole hardening off. Git
571
+ // parses an empty value as no parameters at all (verified, same version),
572
+ // which is the unset this list has no other way to ask for.
573
+ if (hardened)
574
+ list.push({ name: 'GIT_CONFIG_PARAMETERS', value: '' });
575
+ return list;
576
+ }
424
577
  /**
425
578
  * Run an execute_command tool call for an ACP session, in the client's
426
579
  * terminal when it offers one, otherwise locally.
@@ -461,6 +614,33 @@ export async function executeAcpCommand(command, args, cwd, ctx) {
461
614
  // capability in initialize. Otherwise execute locally.
462
615
  if (!ctx.clientSupportsTerminal)
463
616
  return runLocally();
617
+ // The client's terminal is a spawn like any other, and it inherits none of
618
+ // the hardening executeCommandAsync puts on the local one — so over ACP,
619
+ // which is how Zed runs Codeep, a `git status` in a hostile repository ran
620
+ // that repository's `core.fsmonitor` and `filter.<d>.clean` exactly as it
621
+ // did before this hotfix. The validation above stops the argv forms that
622
+ // redirect git, but nothing was stopping its config.
623
+ //
624
+ // Same helper as the local runner, so there is one answer to "what does a
625
+ // spawn that may reach git run with" — see commandEnv(). A refusal fails
626
+ // the command with git's own wording rather than handing it to a terminal
627
+ // this process cannot harden.
628
+ //
629
+ // commandEnv() and NOT shellCommandEnv(): this call site has the argv, and
630
+ // shellCommandEnv() can only scan the spawn's `cwd`. That made one argument
631
+ // the whole difference between the two runners — `git -C vendor/lib status`
632
+ // over ACP got the outer project scanned, so the nested checkout was left
633
+ // with only the always-on GIT_EXECUTING_CONFIG pairs behind it and its
634
+ // `filter.<driver>.clean` still ran (proven, git 2.54), while the same
635
+ // command locally reads `-C` out of the argv and scans where git will
636
+ // actually run.
637
+ let env;
638
+ try {
639
+ env = commandEnv(command, args, cwd);
640
+ }
641
+ catch (error) {
642
+ return fail(error instanceof Error ? error.message : String(error));
643
+ }
464
644
  const { transport, sessionId, signal } = ctx;
465
645
  let terminalId;
466
646
  try {
@@ -469,6 +649,7 @@ export async function executeAcpCommand(command, args, cwd, ctx) {
469
649
  command,
470
650
  args,
471
651
  cwd,
652
+ env: acpEnvList(env, command === 'git'),
472
653
  outputByteLimit: 1_000_000,
473
654
  });
474
655
  if (!created || typeof created.terminalId !== 'string') {
@@ -547,6 +728,43 @@ function persistSessionHistory(session) {
547
728
  return;
548
729
  saveSession(session.codeepSessionId, session.history, session.workspaceRoot);
549
730
  }
731
+ /** The provider's own words from an `API error: 401 - …` message, if any. */
732
+ function providerErrorDetail(message) {
733
+ const body = message.replace(/^[\s\S]*?\b401\s*-\s*/, '').trim();
734
+ let detail = body;
735
+ try {
736
+ const json = JSON.parse(body);
737
+ detail = json?.error?.message ?? json?.message ?? body;
738
+ }
739
+ catch {
740
+ // Not JSON — the text is the detail.
741
+ }
742
+ const text = String(detail).trim();
743
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text;
744
+ }
745
+ /**
746
+ * What to tell the user when a prompt fails on authentication, or null when
747
+ * the failure is about something else.
748
+ *
749
+ * Every 401 used to read "No API key configured", key or no key. Kimi Code
750
+ * answers 401 for its plan limits — no K3 on the plan, K3 past 256K on
751
+ * Plus/Moderato, High-Speed below Pro/Allegretto, an unknown model id
752
+ * (kimi.com/code/docs/en/kimi-code/error-reference.html) — so a subscriber with
753
+ * a perfectly good key was sent to /login when the fix was another model. With
754
+ * a key configured, a 401 now says what else it can mean and quotes the
755
+ * provider.
756
+ */
757
+ export function authFailureNotice(err, providerId, keyConfigured) {
758
+ const is401 = err instanceof ApiError && err.status === 401;
759
+ if (!is401 && !err.message?.includes('API key'))
760
+ return null;
761
+ if (is401 && keyConfigured) {
762
+ const name = PROVIDERS[providerId]?.name ?? providerId;
763
+ const detail = providerErrorDetail(err.message ?? '');
764
+ return `❌ ${name} refused the request (401)${detail ? `: ${detail}` : ''}. A key is configured, so this is not a missing key: the key may be invalid or revoked, or your plan may not include this model or limit (Kimi Code, for one, answers 401 when a plan lacks K3, K3's 1M context or High-Speed). Pick another model with /model, or re-enter the key with /login.`;
765
+ }
766
+ return `❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`;
767
+ }
550
768
  export function startAcpServer(transport = new StdioTransport()) {
551
769
  // ACP sessionId → full AcpSession (includes history + codeep session tracking)
552
770
  const sessions = new Map();
@@ -1186,6 +1404,11 @@ export function startAcpServer(transport = new StdioTransport()) {
1186
1404
  },
1187
1405
  });
1188
1406
  };
1407
+ // Read when the prompt fails, not now: /login during the turn counts.
1408
+ const authNotice = (err) => {
1409
+ const providerId = config.get('provider');
1410
+ return authFailureNotice(err, providerId, Boolean(getApiKey(providerId)));
1411
+ };
1189
1412
  // Ask the user through the client. A person answers this: wait as long
1190
1413
  // as the dialog is open. Only cancelling the prompt stops the wait. No
1191
1414
  // answer (error, cancelled prompt, a reply without an outcome) is null,
@@ -1205,6 +1428,55 @@ export function startAcpServer(transport = new StdioTransport()) {
1205
1428
  // run the agent (/go, custom commands, skill agent steps) run it exactly
1206
1429
  // like a plain prompt.
1207
1430
  const manualMode = session.currentModeId === 'manual';
1431
+ // The one permission dialog this session puts in front of the user.
1432
+ const askAboutToolCall = async (toolCall,
1433
+ // What the agent gate already worked out about this call. Passed rather
1434
+ // than worked out again: trustBearingWrite() stats the path, resolves a
1435
+ // symlinked ancestor and may ask git where this repository keeps its
1436
+ // hooks. `null` is an answer ("writes no such file"); undefined means
1437
+ // the question came from somewhere that has not looked, which is the
1438
+ // only case that pays for the lookup here.
1439
+ known) => {
1440
+ // A write to a file that decides what runs later says so in the
1441
+ // dialog — the editor shows `toolInput`, and "this file controls what
1442
+ // commands git runs" is the part that makes the answer an informed one.
1443
+ const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, session.workspaceRoot);
1444
+ const result = await askUser({
1445
+ toolCallId: `perm_${randomUUID()}`,
1446
+ toolName: toolCall.tool,
1447
+ toolInput: {
1448
+ ...formatToolInputForPermission(toolCall.tool, toolCall.parameters),
1449
+ ...(trustBearing ? { warning: trustBearing.reason } : {}),
1450
+ },
1451
+ status: 'pending',
1452
+ content: [],
1453
+ }, [
1454
+ { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
1455
+ // No "always" for one of those files: the agent answers about this
1456
+ // file only and would not remember the answer anyway.
1457
+ ...(trustBearing ? [] : [{ optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' }]),
1458
+ { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
1459
+ { optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
1460
+ ]);
1461
+ // Map ACP outcome back to PermissionOutcome. No answer
1462
+ // (error, cancelled prompt) denies.
1463
+ if (!result || result.outcome.type === 'cancelled')
1464
+ return 'reject_once';
1465
+ return result.outcome.optionId;
1466
+ };
1467
+ // Auto mode's answer to the agent's permission gate: yes to everything
1468
+ // except a write to a file that decides what runs later, which is asked
1469
+ // about in every mode. Without it the agent would have to refuse those
1470
+ // writes outright, having nobody to ask. It travels under its own key on
1471
+ // `agentRun` and never as `onRequestPermission`: a slash command reads
1472
+ // that key being set as "this session asks the user" (see
1473
+ // acp/commands.ts), and auto mode still runs a skill's shell lines
1474
+ // without asking, as it promises.
1475
+ const autoModeAnswer = async (toolCall, known) => {
1476
+ const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, session.workspaceRoot);
1477
+ // Handed on, so the dialog does not look the same file up a third time.
1478
+ return trustBearing ? askAboutToolCall(toolCall, trustBearing) : 'allow_once';
1479
+ };
1208
1480
  const agentRun = {
1209
1481
  // Manual mode gates write_file/edit_file for this run only, per call —
1210
1482
  // NOT by mutating the global `agentConfirmWriteFile` config, which
@@ -1212,27 +1484,11 @@ export function startAcpServer(transport = new StdioTransport()) {
1212
1484
  // a non-atomic restore.
1213
1485
  extraDangerousTools: manualMode ? ['write_file', 'edit_file'] : undefined,
1214
1486
  // Only request permission in Manual mode
1215
- onRequestPermission: manualMode
1216
- ? async (toolCall) => {
1217
- const result = await askUser({
1218
- toolCallId: `perm_${randomUUID()}`,
1219
- toolName: toolCall.tool,
1220
- toolInput: formatToolInputForPermission(toolCall.tool, toolCall.parameters),
1221
- status: 'pending',
1222
- content: [],
1223
- }, [
1224
- { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
1225
- { optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' },
1226
- { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
1227
- { optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
1228
- ]);
1229
- // Map ACP outcome back to PermissionOutcome. No answer
1230
- // (error, cancelled prompt) denies.
1231
- if (!result || result.outcome.type === 'cancelled')
1232
- return 'reject_once';
1233
- return result.outcome.optionId;
1234
- }
1235
- : undefined,
1487
+ onRequestPermission: manualMode ? askAboutToolCall : undefined,
1488
+ // …and in auto mode, the answer a command that runs the agent uses in
1489
+ // its place, so /go and a skill's agent step get the same one prompt a
1490
+ // plain prompt gets instead of a refusal.
1491
+ onAutoModePermission: manualMode ? undefined : autoModeAnswer,
1236
1492
  // A skill's confirm step ("Deploy to production?") — a one-off
1237
1493
  // question, so no "always" answers.
1238
1494
  confirm: manualMode
@@ -1395,7 +1651,7 @@ export function startAcpServer(transport = new StdioTransport()) {
1395
1651
  }
1396
1652
  }
1397
1653
  },
1398
- onRequestPermission: agentRun.onRequestPermission,
1654
+ onRequestPermission: agentRun.onRequestPermission ?? agentRun.onAutoModePermission,
1399
1655
  extraDangerousTools: agentRun.extraDangerousTools,
1400
1656
  fs: agentRun.fs,
1401
1657
  onExecuteCommand: agentRun.onExecuteCommand,
@@ -1460,8 +1716,8 @@ export function startAcpServer(transport = new StdioTransport()) {
1460
1716
  }
1461
1717
  transport.respond(msg.id, { stopReason: 'cancelled' });
1462
1718
  }
1463
- else if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1464
- sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
1719
+ else if (authNotice(err)) {
1720
+ sendChunk(authNotice(err));
1465
1721
  transport.respond(msg.id, { stopReason: 'end_turn' });
1466
1722
  }
1467
1723
  else if (err instanceof ApiError && err.status >= 500) {
@@ -1482,8 +1738,8 @@ export function startAcpServer(transport = new StdioTransport()) {
1482
1738
  if (err.name === 'AbortError' || abortController.signal.aborted) {
1483
1739
  transport.respond(msg.id, { stopReason: 'cancelled' });
1484
1740
  }
1485
- else if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1486
- sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
1741
+ else if (authNotice(err)) {
1742
+ sendChunk(authNotice(err));
1487
1743
  transport.respond(msg.id, { stopReason: 'end_turn' });
1488
1744
  }
1489
1745
  else if (err instanceof ApiError && err.status >= 500) {
@@ -17,6 +17,7 @@
17
17
  // is "look up the session, mutate config or session state, acknowledge".
18
18
  import { AGENT_MODES, buildConfigOptions } from './server.js';
19
19
  import { config, setProvider, setApiKey, listSessionsWithInfo, deleteSession as deleteSessionFile, } from '../config/index.js';
20
+ import { replacementModelFor } from '../config/providers.js';
20
21
  import { disposeSession as disposeMcpSession } from '../utils/mcpRegistry.js';
21
22
  import { clearPendingPlan } from '../utils/planMode.js';
22
23
  // ─── session/set_mode ─────────────────────────────────────────────────────────
@@ -99,17 +100,16 @@ export function handleSetConfigOption(msg, deps) {
99
100
  */
100
101
  export function applyConfigOption(configId, value) {
101
102
  if (configId === 'model' && typeof value === 'string') {
102
- // value is "providerId/modelId" — split and switch both
103
+ // value is "providerId/modelId" — split and switch both. An editor setting
104
+ // pinned before a retirement (`openai/gpt-6-astra`) names an id the picker
105
+ // no longer has, so the model goes through the same map as a stored config,
106
+ // looked up on the provider actually active: setProvider refuses an unknown
107
+ // id and leaves the old one in place.
103
108
  const slashIdx = value.indexOf('/');
104
- if (slashIdx !== -1) {
105
- const providerId = value.slice(0, slashIdx);
106
- const modelId = value.slice(slashIdx + 1);
107
- setProvider(providerId); // sets provider + defaultModel + protocol
108
- config.set('model', modelId);
109
- }
110
- else {
111
- config.set('model', value);
112
- }
109
+ const modelId = slashIdx !== -1 ? value.slice(slashIdx + 1) : value;
110
+ if (slashIdx !== -1)
111
+ setProvider(value.slice(0, slashIdx)); // sets provider + defaultModel + protocol
112
+ config.set('model', replacementModelFor(config.get('provider'), modelId) ?? modelId);
113
113
  }
114
114
  else if (configId === 'provider' && typeof value === 'string') {
115
115
  // Switch provider without specifying a model — picks the provider's
@@ -1,7 +1,7 @@
1
1
  import { PermissionOutcome } from '../utils/agent.js';
2
2
  import { ProjectContext } from '../utils/project.js';
3
3
  import { ToolCall } from '../utils/tools.js';
4
- import type { FsCallbacks } from '../utils/toolExecution.js';
4
+ import type { FsCallbacks, TrustBearingWrite } from '../utils/toolExecution.js';
5
5
  import type { Message } from '../config/index.js';
6
6
  export interface AgentSessionOptions {
7
7
  prompt: string;
@@ -11,7 +11,18 @@ export interface AgentSessionOptions {
11
11
  onChunk: (text: string) => void;
12
12
  onThought?: (text: string) => void;
13
13
  onToolCall?: (toolCallId: string, toolName: string, kind: string, title: string, status: 'pending' | 'running' | 'finished' | 'error', locations?: string[], rawOutput?: string) => void;
14
- onRequestPermission?: (toolCall: ToolCall) => Promise<PermissionOutcome>;
14
+ /**
15
+ * Ask the user about one tool call. Handed straight to runAgent, so the
16
+ * signature is runAgent's: `trustBearing` is what the agent's own gate
17
+ * already worked out about the call — the file it would write that decides
18
+ * what runs later, or null when it writes no such file — and the dialog
19
+ * words itself from that instead of resolving the path a second time.
20
+ *
21
+ * Declared with one parameter, this type said the second argument did not
22
+ * exist while runAgent passed it on every call, so the one caller that
23
+ * needs it had to cast its way back to the truth.
24
+ */
25
+ onRequestPermission?: (toolCall: ToolCall, trustBearing?: TrustBearingWrite | null) => Promise<PermissionOutcome>;
15
26
  /** Tools to force into the per-run dangerous set (ACP manual mode). */
16
27
  extraDangerousTools?: string[];
17
28
  onExecuteCommand?: (command: string, args: string[], cwd: string) => Promise<{
@@ -1,4 +1,10 @@
1
1
  import { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './protocol.js';
2
+ /**
3
+ * A frame with its obvious credentials blanked.
4
+ *
5
+ * Exported for unit testing (see transport.test.ts).
6
+ */
7
+ export declare function redactCredentials(frame: string): string;
2
8
  type MessageHandler = (msg: JsonRpcRequest | JsonRpcNotification) => void | Promise<unknown>;
3
9
  export interface RequestOptions {
4
10
  /**