diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index e551d751b5..8109a256a4 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -190,6 +190,57 @@ fn apply_custom_version_to_url(url: &str, registry_version: &str, custom_version url.replace(registry_version, custom_version) } +/// Per-agent `env_json` key that opts an npx agent into installing the +/// package's `latest` npm dist-tag instead of the reviewed registry pin. +/// Owned by the "Adapter version" control in Agent Settings, riding the same +/// per-agent env store as pi's `PI_ACP_PI_COMMAND` runtime override and the +/// host-tools knob. Consulted at install/upgrade time ONLY: a launch always +/// runs whatever is installed, and nothing polls npm in the background. +/// +/// Exactly the value `latest` opts in; absence or any other value stays on the +/// pin. Unlike `CODEG_ACP_HOST_TOOLS` there is no process-env second layer to +/// make "absent" ambiguous, so the settings control may delete the key for the +/// pinned default — both readers (this one and `adapterChannelFromEnvText` in +/// acp-agent-settings.tsx) treat absent as pinned. +pub(crate) const ADAPTER_CHANNEL_ENV: &str = "CODEG_ADAPTER_CHANNEL"; +const ADAPTER_CHANNEL_LATEST: &str = "latest"; + +/// Whether a resolved per-agent env opts into the `latest` adapter channel. +/// Takes the MERGED env (`build_runtime_env_from_setting`) rather than raw +/// `env_json`, so it reads the same layers the launch path and the settings +/// page display — a value set through the agent's local config file counts too. +fn adapter_channel_is_latest(env: &BTreeMap) -> bool { + env.get(ADAPTER_CHANNEL_ENV) + .is_some_and(|value| value.trim() == ADAPTER_CHANNEL_LATEST) +} + +/// The npm install spec(s) one prepare call will attempt, in order: the spec to +/// try first, plus the fallback to retry on failure (at most one). +/// +/// An explicit `version_override` (the Custom install dialog) always wins and +/// never falls back — the user asked for that exact version, and quietly +/// installing a different one would relabel their choice. With no override, a +/// latest-channel agent tries the `latest` dist-tag first and keeps the pinned +/// registry spec as the fallback, so npm being unreachable (or a mirror not +/// yet carrying the tag's target) degrades to the reviewed pin instead of a +/// failed install. The default stays byte-identical to `build_npm_install_spec`. +fn npm_install_attempts( + package: &str, + version_override: Option<&str>, + latest_channel: bool, +) -> Result<(String, Option), AcpError> { + let pinned = build_npm_install_spec(package, version_override)?; + let overridden = version_override.is_some_and(|raw| !raw.trim().is_empty()); + if latest_channel && !overridden { + let latest = format!( + "{}@{ADAPTER_CHANNEL_LATEST}", + package_name_from_spec(package) + ); + return Ok((latest, Some(pinned))); + } + Ok((pinned, None)) +} + /// Check whether an NPX agent command is spawnable. /// Uses PATH first, then falls back to the current npm global prefix to handle /// GUI environments that don't inherit the user's shell PATH. @@ -11969,10 +12020,6 @@ pub(crate) async fn acp_prepare_npx_agent_core( let meta = registry::get_agent_meta(agent_type); let result = match meta.distribution { registry::AgentDistribution::Npx { package, cmd, .. } => { - // `version_override` of None/empty keeps the registry-pinned spec; - // a custom version installs `@` instead. - let install_spec = build_npm_install_spec(package, version_override.as_deref())?; - let default = agent_setting_service::AgentDefaultInput { agent_type, registry_id: registry::registry_id_for(agent_type).to_string(), @@ -11982,11 +12029,25 @@ pub(crate) async fn acp_prepare_npx_agent_core( .await .map_err(|e| AcpError::protocol(e.to_string()))?; - let existing = agent_setting_service::get_by_agent_type(&db.conn, agent_type) + let setting = agent_setting_service::get_by_agent_type(&db.conn, agent_type) .await .ok() - .flatten() - .and_then(|m| m.installed_version); + .flatten(); + let existing = setting.as_ref().and_then(|m| m.installed_version.clone()); + // The latest-channel opt-in reads the same merged env layers the + // launch and the settings page resolve, so the control can never + // show one channel while the install applies another. + let latest_channel = adapter_channel_is_latest(&build_runtime_env_from_setting( + agent_type, + setting.as_ref(), + load_agent_local_config_json(agent_type).as_deref(), + )); + // `version_override` of None/empty keeps the channel's spec (the + // registry pin, or `@latest` for a latest-channel agent); a + // custom version installs `@` instead, on either + // channel. + let (first_spec, fallback_spec) = + npm_install_attempts(package, version_override.as_deref(), latest_channel)?; // Best-effort uninstall before reinstall. Forces npm to re-resolve // the dependency graph from scratch, which is required for @@ -12016,11 +12077,58 @@ pub(crate) async fn acp_prepare_npx_agent_core( emitter, &task_id, AgentInstallEventKind::Log, - format!("Installing {} ({install_spec})", meta.name), + format!("Installing {} ({first_spec})", meta.name), ); - install_npm_global_package_streaming(&install_spec, &task_id, emitter) - .await - .map_err(|e| annotate_npm_bootstrap_failure(&install_spec, e))?; + let install_spec = match install_npm_global_package_streaming( + &first_spec, + &task_id, + emitter, + ) + .await + { + Ok(()) => first_spec, + Err(err) => { + // FAIL SAFE TO THE PIN. A latest-channel install can die on + // things the pin does not (npm unreachable, a mirror not yet + // carrying the tag's target, a yanked release), and the user + // asked for "newest when possible", not "nothing unless + // newest". Retry the reviewed pinned spec, saying so in the + // same install log — and let the recorded installed version + // report what actually landed. + let Some(pinned_spec) = fallback_spec else { + return Err(annotate_npm_bootstrap_failure(&first_spec, err)); + }; + let err = annotate_npm_bootstrap_failure(&first_spec, err); + tracing::warn!( + "[acp] latest install {first_spec} failed ({err}); \ + falling back to pinned {pinned_spec}" + ); + emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Log, + format!("ERROR: installing {first_spec} failed: {err}"), + ); + emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Log, + format!( + "Falling back to the pinned version ({pinned_spec})..." + ), + ); + emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Log, + format!("Installing {} ({pinned_spec})", meta.name), + ); + install_npm_global_package_streaming(&pinned_spec, &task_id, emitter) + .await + .map_err(|e| annotate_npm_bootstrap_failure(&pinned_spec, e))?; + pinned_spec + } + }; // For a bootstrap-wrapper package (hermes-agent), npm metadata // existing does NOT mean the agent can run: a skipped or broken @@ -15940,6 +16048,91 @@ wire_api = "chat" assert!(build_npm_install_spec("cline@3.0.9", Some("latest")).is_err()); } + // The pinned default is byte-identical to what `build_npm_install_spec` + // produced before the channel existed, with no fallback attempt. + #[test] + fn npm_install_attempts_defaults_to_the_pinned_spec() { + assert_eq!( + npm_install_attempts("@google/gemini-cli@0.44.1", None, false).unwrap(), + ("@google/gemini-cli@0.44.1".to_string(), None) + ); + assert_eq!( + npm_install_attempts("@google/gemini-cli@0.44.1", Some(" "), false).unwrap(), + ("@google/gemini-cli@0.44.1".to_string(), None) + ); + } + + // The latest channel tries the `latest` dist-tag first and keeps the + // registry pin as the fallback, so a failed latest install degrades to the + // reviewed version instead of no install at all. + #[test] + fn npm_install_attempts_maps_latest_channel_onto_the_dist_tag() { + assert_eq!( + npm_install_attempts("@google/gemini-cli@0.44.1", None, true).unwrap(), + ( + "@google/gemini-cli@latest".to_string(), + Some("@google/gemini-cli@0.44.1".to_string()) + ) + ); + // A blank override is the same as none. + assert_eq!( + npm_install_attempts("cline@3.0.9", Some(" "), true).unwrap(), + ("cline@latest".to_string(), Some("cline@3.0.9".to_string())) + ); + } + + // An explicit custom version wins on either channel and never falls back: + // the user asked for that exact version, and quietly installing another + // would relabel their choice. + #[test] + fn npm_install_attempts_lets_an_explicit_override_win() { + assert_eq!( + npm_install_attempts("cline@3.0.9", Some("2.0.0"), true).unwrap(), + ("cline@2.0.0".to_string(), None) + ); + assert!(npm_install_attempts("cline@3.0.9", Some("nightly"), true).is_err()); + } + + // The latest channel introduces a NEW SPEC SHAPE (`@latest`), and the + // spec — not the agent type — is what every downstream step keys off. + // `npm_package_requires_scripts` is the one that bites: hermes-agent's + // postinstall bootstraps its runtime, and it is the only package codeg + // force-enables lifecycle scripts for. A spec shape that hid the package + // name from it would install a shim that only fails later, at connect, + // with "runtime is not ready". Both attempts must be recognized, since + // either one can be the spec that actually lands. + #[test] + fn the_latest_spec_still_names_the_package_downstream_readers_key_off() { + let (latest, pinned) = npm_install_attempts("hermes-agent@0.21.0", None, true).unwrap(); + assert_eq!(latest, "hermes-agent@latest"); + assert!(npm_package_requires_scripts(&latest)); + assert!(npm_package_requires_scripts(&pinned.unwrap())); + // And the `@latest` tag is never mistaken for a version number, so a + // successful latest install falls through to the real post-install + // probe instead of recording "latest" as the installed version. + assert_eq!(version_from_package_spec(&latest), None); + } + + // Only the exact (trimmed) sentinel opts into the latest channel; absence + // and every other value stay on the pin, matching the frontend reader. + #[test] + fn adapter_channel_reads_only_the_exact_latest_sentinel() { + let env = |value: Option<&str>| { + let mut map = BTreeMap::new(); + map.insert("XAI_API_KEY".to_string(), "abc".to_string()); + if let Some(value) = value { + map.insert(ADAPTER_CHANNEL_ENV.to_string(), value.to_string()); + } + map + }; + assert!(!adapter_channel_is_latest(&env(None))); + assert!(adapter_channel_is_latest(&env(Some("latest")))); + assert!(adapter_channel_is_latest(&env(Some(" latest ")))); + assert!(!adapter_channel_is_latest(&env(Some("pinned")))); + assert!(!adapter_channel_is_latest(&env(Some("Latest")))); + assert!(!adapter_channel_is_latest(&env(Some("")))); + } + #[test] fn apply_custom_version_to_url_substitutes_all_occurrences() { // Codex URL embeds the version twice (path tag + asset filename). diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 5bf0706ad0..4532f2ec38 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest" import { + adapterChannelFromEnv, + adapterChannelFromEnvText, applyClaudeProviderToConfigText, buildCodexSandboxConfig, codexSandboxBaselineOf, @@ -23,6 +25,7 @@ import { patchImportantConfigText, codexSandboxSeedsAcpPreset, rebaseDeepSeekDraft, + setAdapterChannel, setClaudeEnvFlagInConfigText, setHostToolsAgentMode, showsCodexReadOnlyAcpWarning, @@ -826,6 +829,77 @@ describe("buildVersionCheck", () => { expect(check?.status).toBe("fail") expect(check?.fixes).toHaveLength(0) }) + + // The opt-in latest channel keeps the Upgrade action available in the pass + // state: an installed latest-channel agent normally sits AT or AHEAD of the + // pin, so the compare-to-pin flow would never offer an upgrade again — and + // codeg cannot know whether npm has something newer, because nothing polls + // in the background. + it("keeps Upgrade available for an installed latest-channel npx agent", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "gemini" as AgentType, + distribution_type: "npx", + registry_version: "0.57.0", + installed_version: "0.60.0", + env: { CODEG_ADAPTER_CHANNEL: "latest" }, + }) + ) + expect(check?.status).toBe("pass") + expect(check?.message).toContain("Latest channel") + expect(check?.fixes.some((fix) => fix.kind === "upgrade_npx")).toBe(true) + expect(check?.fixes.some((fix) => fix.kind === "uninstall_npx")).toBe(true) + }) + + // The pinned default's pass state is byte-for-byte what it was before the + // channel existed. + it("leaves the pinned default's pass state unchanged", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "gemini" as AgentType, + distribution_type: "npx", + registry_version: "0.57.0", + installed_version: "0.57.0", + }) + ) + expect(check?.status).toBe("pass") + expect(check?.message).toContain("Already latest") + expect(check?.fixes.some((fix) => fix.kind === "upgrade_npx")).toBe(false) + }) + + // A latest-channel agent below the pin still warns: the upgrade it offers + // resolves the `latest` dist-tag, which is at least the pin. + it("still warns when a latest-channel agent sits below the pin", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "gemini" as AgentType, + distribution_type: "npx", + registry_version: "0.57.0", + installed_version: "0.50.0", + env: { CODEG_ADAPTER_CHANNEL: "latest" }, + }) + ) + expect(check?.status).toBe("warn") + expect(check?.message).toContain("Upgrade available") + }) + + // The channel is an npx concept (an npm dist-tag), so the same env key on a + // binary agent must not rewrite its version card. + it("ignores the channel key on a non-npx agent", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "open_code" as AgentType, + distribution_type: "binary", + registry_version: "1.0.0", + installed_version: "1.0.0", + env: { CODEG_ADAPTER_CHANNEL: "latest" }, + }) + ) + expect(check?.message).toContain("Already latest") + expect(check?.fixes.some((fix) => fix.kind === "upgrade_binary")).toBe( + false + ) + }) }) describe("getAgentChecks uv gating", () => { @@ -1761,6 +1835,50 @@ describe("host-tools toggle — hand the fs/terminal channels back to the agent" }) }) +describe("adapter-channel control — opt into the latest adapter release", () => { + const KEY = "CODEG_ADAPTER_CHANNEL" + + it("defaults to pinned for an agent that has never touched the control", () => { + expect(adapterChannelFromEnvText("")).toBe("pinned") + expect(adapterChannelFromEnvText("XAI_API_KEY=abc")).toBe("pinned") + expect(adapterChannelFromEnv({})).toBe("pinned") + }) + + it("round-trips latest and back to pinned", () => { + const latest = setAdapterChannel("XAI_API_KEY=abc", "latest") + expect(latest).toContain(`${KEY}=latest`) + expect(adapterChannelFromEnvText(latest)).toBe("latest") + + // Pinned DELETES the key: unlike the host-tools knob there is no + // process-env second layer that could make "absent" mean something else, + // so absent is unambiguously the default on both sides, and the raw + // editor stays free of a key that only restates it. + const pinned = setAdapterChannel(latest, "pinned") + expect(pinned).not.toContain(KEY) + expect(adapterChannelFromEnvText(pinned)).toBe("pinned") + expect(pinned).toContain("XAI_API_KEY=abc") + }) + + it("reads only the exact sentinel, matching the Rust reader", () => { + // `adapter_channel_is_latest` (commands/acp.rs) treats exactly the trimmed + // `latest` as the opt-in; everything else stays on the reviewed pin. + expect(adapterChannelFromEnvText(`${KEY}=latest`)).toBe("latest") + expect(adapterChannelFromEnvText(`${KEY} = latest `)).toBe("latest") + expect(adapterChannelFromEnvText(`${KEY}=Latest`)).toBe("pinned") + expect(adapterChannelFromEnvText(`${KEY}=pinned`)).toBe("pinned") + expect(adapterChannelFromEnvText(`${KEY}=`)).toBe("pinned") + expect(adapterChannelFromEnv({ [KEY]: "latest" })).toBe("latest") + expect(adapterChannelFromEnv({ [KEY]: "nightly" })).toBe("pinned") + }) + + it("does not double up when selected twice", () => { + const once = setAdapterChannel("", "latest") + const twice = setAdapterChannel(once, "latest") + expect(twice).toBe(once) + expect(twice.match(new RegExp(KEY, "g"))).toHaveLength(1) + }) +}) + describe("rebaseDeepSeekDraft", () => { // Only the fields this helper reads or writes; the rest of AgentDraft is // spread through untouched, which the identity assertion below pins. diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 0bf58a8f1d..1586960d2c 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -548,6 +548,56 @@ export function setHostToolsAgentMode( }) } +/** + * Per-agent `env_json` key that opts an npx agent into installing the + * package's `latest` npm dist-tag instead of the maintainer-reviewed pin. + * Same storage as pi's runtime override and the host-tools knob above. The + * backend reads it at install/upgrade time only: a launch always runs whatever + * is installed, nothing polls npm in the background, and a failed latest + * install falls back to the pinned version with a note in the install log. + */ +const ADAPTER_CHANNEL_ENV = "CODEG_ADAPTER_CHANNEL" +const ADAPTER_CHANNEL_LATEST = "latest" + +export type AdapterChannel = "pinned" | "latest" + +/** + * Which adapter channel an env draft selects. Anything other than the exact + * (trimmed) `latest` sentinel reads as pinned, matching the Rust reader + * (`adapter_channel_is_latest`), which treats the pin as the only default. + */ +export function adapterChannelFromEnvText(envText: string): AdapterChannel { + return parseEnvText(envText)[ADAPTER_CHANNEL_ENV]?.trim() === + ADAPTER_CHANNEL_LATEST + ? "latest" + : "pinned" +} + +/** [`adapterChannelFromEnvText`] over the saved env map the backend reports. */ +export function adapterChannelFromEnv( + env: Record +): AdapterChannel { + return env[ADAPTER_CHANNEL_ENV]?.trim() === ADAPTER_CHANNEL_LATEST + ? "latest" + : "pinned" +} + +/** + * Select the adapter channel in an env draft. Pinned DELETES the key: unlike + * the host-tools knob there is no process-env second layer that could make + * "absent" mean something else, so absent is unambiguously the pinned default + * on both sides, and the raw editor stays free of a key that only restates it. + */ +export function setAdapterChannel( + envText: string, + channel: AdapterChannel +): string { + return patchEnvText(envText, { + [ADAPTER_CHANNEL_ENV]: + channel === "latest" ? ADAPTER_CHANNEL_LATEST : undefined, + }) +} + interface ImportantEnvKeys { apiBaseUrl: string[] apiKey: string[] @@ -3930,6 +3980,12 @@ export function buildVersionCheck( const withCustomInstall = (fixes: UiFixAction[]): UiFixAction[] => supportsCustomInstall ? [...fixes, customInstallFix] : fixes + // The opt-in "Adapter version: Latest" channel (npx agents only) — install + // and upgrade actions resolve the `latest` dist-tag instead of the pin. + const latestChannel = + agent.distribution_type === "npx" && + adapterChannelFromEnv(agent.env) === "latest" + if (!agent.installed_version) { return { check_id: "version_status", @@ -4049,6 +4105,37 @@ export function buildVersionCheck( } } + // A latest-channel agent's installed version normally sits AT or AHEAD of + // the pin, so the compare-to-pin branch above never offers an upgrade again + // — and codeg cannot know whether npm has something newer, because nothing + // polls in the background (by design). Keep the Upgrade action available: + // it resolves the `latest` dist-tag on demand, and "Already latest" would + // claim a comparison that was never made. + if (latestChannel) { + return { + check_id: "version_status", + label: acpText("version.statusLabel", "Version Status"), + status: "pass", + message: acpText( + "version.latestChannel", + "{versionText}. Latest channel is on; Upgrade installs the newest release.", + { versionText } + ), + fixes: withCustomInstall([ + { + label: acpText("actions.upgrade", "Upgrade"), + kind: upgradeAction, + payload: agent.agent_type, + }, + { + label: acpText("actions.uninstall", "Uninstall"), + kind: uninstallAction, + payload: agent.agent_type, + }, + ]), + } + } + return { check_id: "version_status", label: acpText("version.statusLabel", "Version Status"), @@ -7916,6 +8003,58 @@ export function AcpAgentSettings() { aria-label={t("hostTools.label")} /> + {/* + Same contract as the host-tools switch above: backed by the + `envText` draft, persisted by the one Save button. Npx + agents only — a binary or uvx install has no npm dist-tag + to track. + */} + {selectedAgent.distribution_type === "npx" && ( +
+
+ +

+ {t("adapterChannel.description")} +

+ {adapterChannelFromEnvText(selectedDraft.envText) === + "latest" && ( +

+ {t("adapterChannel.latestWarning")} +

+ )} +
+ +
+ )}