diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/apps/daemon/tests/agents.test.ts b/apps/daemon/tests/agents.test.ts index b3789b974..b1f98123d 100644 --- a/apps/daemon/tests/agents.test.ts +++ b/apps/daemon/tests/agents.test.ts @@ -44,6 +44,7 @@ const originalAgentHome = process.env.OD_AGENT_HOME; const originalDaemonUrl = process.env.OD_DAEMON_URL; const originalToolToken = process.env.OD_TOOL_TOKEN; const originalNpmConfigPrefix = process.env.NPM_CONFIG_PREFIX; +const originalVpHome = process.env.VP_HOME; const originalFetch = globalThis.fetch; afterEach(() => { @@ -78,6 +79,11 @@ afterEach(() => { } else { process.env.NPM_CONFIG_PREFIX = originalNpmConfigPrefix; } + if (originalVpHome == null) { + delete process.env.VP_HOME; + } else { + process.env.VP_HOME = originalVpHome; + } globalThis.fetch = originalFetch; }); @@ -1156,6 +1162,46 @@ fsTest( }, ); +fsTest( + 'resolveAgentExecutable searches ~/.vite-plus/bin under a minimal GUI-launched PATH (vp global install)', + () => { + const home = mkdtempSync(join(tmpdir(), 'od-agents-vp-home-')); + try { + const dir = join(home, '.vite-plus', 'bin'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'vp-cli-probe'), ''); + chmodSync(join(dir, 'vp-cli-probe'), 0o755); + process.env.OD_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const resolved = resolveAgentExecutable({ bin: 'vp-cli-probe' }); + assert.equal(resolved, join(dir, 'vp-cli-probe')); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, +); + +fsTest( + 'resolveAgentExecutable honors $VP_HOME/bin when the custom Vite+ home is outside PATH', + () => { + const vpHome = mkdtempSync(join(tmpdir(), 'od-agents-vp-custom-')); + try { + const dir = join(vpHome, 'bin'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'vp-cli-probe'), ''); + chmodSync(join(dir, 'vp-cli-probe'), 0o755); + process.env.PATH = '/usr/bin:/bin'; + process.env.VP_HOME = vpHome; + + const resolved = resolveAgentExecutable({ bin: 'vp-cli-probe' }); + assert.equal(resolved, join(dir, 'vp-cli-probe')); + } finally { + rmSync(vpHome, { recursive: true, force: true }); + } + }, +); + // Test isolation: when OD_AGENT_HOME points at a sandbox, an exported // $NPM_CONFIG_PREFIX / $npm_config_prefix on the developer's or CI // runner's environment must not leak a real /bin into the @@ -1200,6 +1246,34 @@ fsTest( }, ); +fsTest( + 'OD_AGENT_HOME isolates resolution from $VP_HOME leakage', + () => { + const sandbox = mkdtempSync(join(tmpdir(), 'od-agents-vp-sandbox-')); + const realVpHome = mkdtempSync(join(tmpdir(), 'od-agents-vp-real-home-')); + const realVpBin = join(realVpHome, 'bin'); + try { + mkdirSync(realVpBin, { recursive: true }); + writeFileSync(join(realVpBin, 'vp-cli-probe'), ''); + chmodSync(join(realVpBin, 'vp-cli-probe'), 0o755); + + process.env.OD_AGENT_HOME = sandbox; + process.env.PATH = '/usr/bin:/bin'; + process.env.VP_HOME = realVpHome; + + const resolved = resolveAgentExecutable({ bin: 'vp-cli-probe' }); + assert.equal( + resolved, + null, + `OD_AGENT_HOME sandbox must not see the real $VP_HOME bin; got ${resolved}`, + ); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + rmSync(realVpHome, { recursive: true, force: true }); + } + }, +); + // DeepSeek TUI's exec subcommand requires the prompt as a positional // argument (no `-` stdin sentinel; clap declares `prompt: String` as a // required field). `--auto` enables agentic mode with auto-approval — diff --git a/apps/packaged/src/sidecars.ts b/apps/packaged/src/sidecars.ts index e0005f3e8..6fbd73beb 100644 --- a/apps/packaged/src/sidecars.ts +++ b/apps/packaged/src/sidecars.ts @@ -32,7 +32,7 @@ import type { PackagedWebOutputMode } from "./config.js"; import type { PackagedNamespacePaths } from "./paths.js"; const require = createRequire(import.meta.url); -const PACKAGED_CHILD_ENV_ALLOWLIST = ["HOME", "LANG", "LC_ALL", "LOGNAME", "TMPDIR", "USER"] as const; +const PACKAGED_CHILD_ENV_ALLOWLIST = ["HOME", "LANG", "LC_ALL", "LOGNAME", "TMPDIR", "USER", "VP_HOME"] as const; function shouldForwardPackagedChildEnv(key: string, includeProviderSecrets = false): boolean { return ( @@ -173,7 +173,7 @@ function extractPort(url: string): string { // resolver and this PATH builder cannot drift again. See issue #442. const PACKAGED_POSIX_SYSTEM_BINS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] as const; -function resolvePackagedPathEnv(basePath = process.env.PATH ?? ""): string { +export function resolvePackagedPathEnv(basePath = process.env.PATH ?? ""): string { const candidates = [ ...basePath.split(delimiter), ...wellKnownUserToolchainBins(), @@ -182,7 +182,10 @@ function resolvePackagedPathEnv(basePath = process.env.PATH ?? ""): string { return [...new Set(candidates.filter((entry) => entry.length > 0))].join(delimiter); } -function resolvePackagedChildBaseEnv(env: NodeJS.ProcessEnv = process.env,includeProviderSecrets = false,): NodeJS.ProcessEnv { +export function resolvePackagedChildBaseEnv( + env: NodeJS.ProcessEnv = process.env, + includeProviderSecrets = false, +): NodeJS.ProcessEnv { const baseEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(env)) { if (value != null && value.length > 0 && shouldForwardPackagedChildEnv(key, includeProviderSecrets)) { diff --git a/apps/packaged/tests/sidecars.test.ts b/apps/packaged/tests/sidecars.test.ts index b4f30656b..af1fa0ca1 100644 --- a/apps/packaged/tests/sidecars.test.ts +++ b/apps/packaged/tests/sidecars.test.ts @@ -16,9 +16,17 @@ * @see https://github.com/nexu-io/open-design/issues/710 */ import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { resolveDaemonStatusTimeoutMs, waitForStatus } from '../src/sidecars.js'; +import { + resolveDaemonStatusTimeoutMs, + resolvePackagedChildBaseEnv, + resolvePackagedPathEnv, + waitForStatus, +} from '../src/sidecars.js'; describe('resolveDaemonStatusTimeoutMs', () => { it('uses the default 35-second budget for normal cold boots', () => { @@ -54,6 +62,40 @@ describe('resolveDaemonStatusTimeoutMs', () => { }); }); +describe('packaged child Vite+ environment forwarding', () => { + it('keeps VP_HOME in the packaged child base env without forwarding unrelated variables', () => { + const env = resolvePackagedChildBaseEnv({ + HOME: '/Users/tester', + LANG: 'en_US.UTF-8', + RANDOM_INTERNAL_FLAG: 'drop-me', + VP_HOME: '/Users/tester/.custom-vite-plus', + }); + + expect(env).toMatchObject({ + HOME: '/Users/tester', + LANG: 'en_US.UTF-8', + VP_HOME: '/Users/tester/.custom-vite-plus', + }); + expect(env.RANDOM_INTERNAL_FLAG).toBeUndefined(); + }); + + it('adds custom VP_HOME/bin to the packaged PATH builder', () => { + const vpHome = mkdtempSync(join(tmpdir(), 'od-packaged-vp-home-')); + const originalVpHome = process.env.VP_HOME; + try { + process.env.VP_HOME = vpHome; + const pathEntries = resolvePackagedPathEnv('/usr/bin').split(delimiter); + + expect(pathEntries).toContain('/usr/bin'); + expect(pathEntries).toContain(join(vpHome, 'bin')); + } finally { + if (originalVpHome == null) delete process.env.VP_HOME; + else process.env.VP_HOME = originalVpHome; + rmSync(vpHome, { recursive: true, force: true }); + } + }); +}); + /** * Build a child-process stand-in that satisfies the `watch.child` * shape `waitForStatus` consumes. We only use `once('exit')`, diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 10cbd96f7..59b18ff6f 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -2,7 +2,7 @@ import { execFile, spawn, type ChildProcess, type StdioOptions } from "node:chil import { existsSync, readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; export type CommandInvocation = { @@ -429,6 +429,17 @@ export type WellKnownUserToolchainOptions = { env?: NodeJS.ProcessEnv; }; +function resolveUserScopedHome(raw: string | undefined, home: string): string | null { + if (typeof raw !== "string") return null; + const value = raw.trim(); + if (value.length === 0) return null; + if (value === "~") return home; + if (value.startsWith("~/") || value.startsWith("~\\")) { + return join(home, value.slice(2)); + } + return isAbsolute(value) ? value : null; +} + // Single source of truth for "user-level CLI install locations the daemon // must search even when launched with a minimal PATH". GUI launchers // (macOS .app bundles, Linux .desktop files) typically inherit a stripped @@ -445,6 +456,14 @@ export function wellKnownUserToolchainBins( const includeSystemBins = options.includeSystemBins ?? process.platform !== "win32"; const env = options.env ?? process.env; const dirs: string[] = []; + // Vite+ global installs expose CLI shims from VP_HOME/bin (default + // ~/.vite-plus/bin). An explicit VP_HOME is the most specific signal for + // vp-managed shims, so it wins over other global package-manager prefixes + // when a CLI name exists in multiple stores. + const vpHome = resolveUserScopedHome(env.VP_HOME, home); + if (vpHome) { + dirs.push(join(vpHome, "bin")); + } // The user's *explicit* npm prefix outranks every conventional // location below — including `~/.local/bin`. The env var is the // user's current npm configuration, so a binary installed via @@ -469,6 +488,7 @@ export function wellKnownUserToolchainBins( } dirs.push( join(home, ".local", "bin"), + join(home, ".vite-plus", "bin"), join(home, ".opencode", "bin"), join(home, ".bun", "bin"), join(home, ".volta", "bin"), diff --git a/packages/platform/tests/index.test.ts b/packages/platform/tests/index.test.ts index 5907718a9..955a7e063 100644 --- a/packages/platform/tests/index.test.ts +++ b/packages/platform/tests/index.test.ts @@ -338,6 +338,16 @@ describe("wellKnownUserToolchainBins", () => { } }); + it("includes ~/.vite-plus/bin so vp-managed global shims resolve under GUI launchers", () => { + const home = mkdtempSync(join(tmpdir(), "wkutb-vp-")); + try { + const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false }); + expect(dirs).toContain(join(home, ".vite-plus", "bin")); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + it("appends $NPM_CONFIG_PREFIX/bin when set so corporate prefixes resolve", () => { const home = mkdtempSync(join(tmpdir(), "wkutb-prefix-")); const customPrefix = mkdtempSync(join(tmpdir(), "wkutb-custom-")); @@ -370,6 +380,41 @@ describe("wellKnownUserToolchainBins", () => { } }); + it("prepends $VP_HOME/bin and expands ~/ so custom Vite+ homes outrank the default", () => { + const home = mkdtempSync(join(tmpdir(), "wkutb-vp-home-")); + try { + const dirs = wellKnownUserToolchainBins({ + home, + env: { VP_HOME: "~/custom-vp-home" }, + includeSystemBins: false, + }); + expect(dirs[0]).toBe(join(home, "custom-vp-home", "bin")); + expect(dirs).toContain(join(home, ".vite-plus", "bin")); + expect(dirs.indexOf(join(home, ".vite-plus", "bin"))).toBeGreaterThan(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("places $VP_HOME/bin before $NPM_CONFIG_PREFIX/bin when both explicit homes are set", () => { + const home = mkdtempSync(join(tmpdir(), "wkutb-vp-npm-order-")); + const npmPrefix = mkdtempSync(join(tmpdir(), "wkutb-vp-npm-prefix-")); + try { + const dirs = wellKnownUserToolchainBins({ + home, + env: { NPM_CONFIG_PREFIX: npmPrefix, VP_HOME: "~/custom-vp-home" }, + includeSystemBins: false, + }); + const vpIdx = dirs.indexOf(join(home, "custom-vp-home", "bin")); + const npmIdx = dirs.indexOf(join(npmPrefix, "bin")); + expect(vpIdx).toBe(0); + expect(npmIdx).toBeGreaterThan(vpIdx); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(npmPrefix, { recursive: true, force: true }); + } + }); + it("does not append a prefix entry when neither env var is set", () => { const home = mkdtempSync(join(tmpdir(), "wkutb-noprefix-")); try { @@ -390,7 +435,7 @@ describe("wellKnownUserToolchainBins", () => { // including ~/.local/bin (which is also a shared pip --user / cargo // install dumping ground). Conventional locations frequently retain // *stale* binaries from an older prefix. - it("places $NPM_CONFIG_PREFIX/bin before every conventional location, including ~/.local/bin", () => { + it("places $NPM_CONFIG_PREFIX/bin before every conventional location when VP_HOME is unset", () => { const home = mkdtempSync(join(tmpdir(), "wkutb-prefix-order-")); const customPrefix = mkdtempSync(join(tmpdir(), "wkutb-custom-order-")); try {