From 58002fb28a41d5dfa09cbde4fb1e7ab355933267 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 12 Feb 2026 15:49:40 -0800 Subject: [PATCH] fix: pin nested letta resolution in dev subagent shells (#937) --- src/tests/tools/shell-env.test.ts | 123 ++++++++++++++++++++++++++++++ src/tools/impl/shellEnv.ts | 110 ++++++++++++++++++++++++-- 2 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 src/tests/tools/shell-env.test.ts diff --git a/src/tests/tools/shell-env.test.ts b/src/tests/tools/shell-env.test.ts new file mode 100644 index 0000000..b086cb6 --- /dev/null +++ b/src/tests/tools/shell-env.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as path from "node:path"; +import { + ensureLettaShimDir, + getShellEnv, + resolveLettaInvocation, +} from "../../tools/impl/shellEnv"; + +describe("shellEnv letta shim", () => { + test("resolveLettaInvocation prefers explicit launcher env", () => { + const invocation = resolveLettaInvocation( + { + LETTA_CODE_BIN: "/tmp/custom-letta", + LETTA_CODE_BIN_ARGS_JSON: JSON.stringify(["/tmp/entry.ts"]), + }, + ["bun", "/something/else.ts"], + "/opt/homebrew/bin/bun", + ); + + expect(invocation).toEqual({ + command: "/tmp/custom-letta", + args: ["/tmp/entry.ts"], + }); + }); + + test("resolveLettaInvocation infers dev entrypoint launcher", () => { + const invocation = resolveLettaInvocation( + {}, + ["bun", "/Users/example/dev/letta-code-prod/src/index.ts"], + "/opt/homebrew/bin/bun", + ); + + expect(invocation).toEqual({ + command: "/opt/homebrew/bin/bun", + args: ["/Users/example/dev/letta-code-prod/src/index.ts"], + }); + }); + + test("resolveLettaInvocation returns null for unrelated argv scripts", () => { + const invocation = resolveLettaInvocation( + {}, + ["bun", "/Users/example/dev/another-project/scripts/run.ts"], + "/opt/homebrew/bin/bun", + ); + + expect(invocation).toBeNull(); + }); + + test("resolveLettaInvocation does not infer production letta.js entrypoint", () => { + const invocation = resolveLettaInvocation( + {}, + [ + "/usr/local/bin/node", + "/usr/local/lib/node_modules/@letta-ai/letta-code/letta.js", + ], + "/usr/local/bin/node", + ); + + expect(invocation).toBeNull(); + }); + + test("letta shim resolves first on PATH for subprocess shells", () => { + if (process.platform === "win32") { + return; + } + + const shimDir = ensureLettaShimDir({ + command: "/bin/echo", + args: ["shimmed-letta"], + }); + expect(shimDir).toBeTruthy(); + + const env = { + ...process.env, + PATH: `${shimDir}${path.delimiter}${process.env.PATH || ""}`, + }; + const whichResult = spawnSync("which", ["letta"], { + env, + encoding: "utf8", + }); + expect(whichResult.status).toBe(0); + expect(whichResult.stdout.trim()).toBe( + path.join(shimDir as string, "letta"), + ); + + const versionResult = spawnSync("letta", ["--version"], { + env, + encoding: "utf8", + }); + expect(versionResult.status).toBe(0); + expect(versionResult.stdout.trim()).toBe("shimmed-letta --version"); + }); + + test("getShellEnv sets launcher metadata when explicit launcher env is provided", () => { + const originalBin = process.env.LETTA_CODE_BIN; + const originalArgs = process.env.LETTA_CODE_BIN_ARGS_JSON; + + process.env.LETTA_CODE_BIN = "/tmp/explicit-bin"; + process.env.LETTA_CODE_BIN_ARGS_JSON = JSON.stringify([ + "/tmp/entrypoint.js", + ]); + + try { + const env = getShellEnv(); + expect(env.LETTA_CODE_BIN).toBe("/tmp/explicit-bin"); + expect(env.LETTA_CODE_BIN_ARGS_JSON).toBe( + JSON.stringify(["/tmp/entrypoint.js"]), + ); + } finally { + if (originalBin === undefined) { + delete process.env.LETTA_CODE_BIN; + } else { + process.env.LETTA_CODE_BIN = originalBin; + } + if (originalArgs === undefined) { + delete process.env.LETTA_CODE_BIN_ARGS_JSON; + } else { + process.env.LETTA_CODE_BIN_ARGS_JSON = originalArgs; + } + } + }); +}); diff --git a/src/tools/impl/shellEnv.ts b/src/tools/impl/shellEnv.ts index b8f43bb..a7d4503 100644 --- a/src/tools/impl/shellEnv.ts +++ b/src/tools/impl/shellEnv.ts @@ -4,7 +4,9 @@ * including bundled tools like ripgrep in PATH and Letta context for skill scripts. */ +import { mkdirSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { getServerUrl } from "../../agent/client"; @@ -45,21 +47,119 @@ function getPackageNodeModulesDir(): string | undefined { } } +interface LettaInvocation { + command: string; + args: string[]; +} + +const LETTA_BIN_ARGS_ENV = "LETTA_CODE_BIN_ARGS_JSON"; + +function parseInvocationArgs(raw: string | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if ( + Array.isArray(parsed) && + parsed.every((item) => typeof item === "string") + ) { + return parsed; + } + } catch { + // Ignore malformed JSON and fall back to empty args. + } + return []; +} + +function isDevLettaEntryScript(scriptPath: string): boolean { + const normalized = scriptPath.replaceAll("\\", "/"); + return normalized.endsWith("/src/index.ts"); +} + +export function resolveLettaInvocation( + env: NodeJS.ProcessEnv = process.env, + argv: string[] = process.argv, + execPath: string = process.execPath, +): LettaInvocation | null { + const explicitBin = env.LETTA_CODE_BIN?.trim(); + if (explicitBin) { + return { + command: explicitBin, + args: parseInvocationArgs(env[LETTA_BIN_ARGS_ENV]), + }; + } + + const scriptPath = argv[1] || ""; + if (scriptPath && isDevLettaEntryScript(scriptPath)) { + return { command: execPath, args: [scriptPath] }; + } + + return null; +} + +function shellEscape(arg: string): string { + return `'${arg.replaceAll("'", `'"'"'`)}'`; +} + +export function ensureLettaShimDir(invocation: LettaInvocation): string | null { + if (!invocation.command) return null; + + const shimDir = path.join(tmpdir(), "letta-code-shell-shim"); + mkdirSync(shimDir, { recursive: true }); + + if (process.platform === "win32") { + const cmdPath = path.join(shimDir, "letta.cmd"); + const quotedCommand = `"${invocation.command.replaceAll('"', '""')}"`; + const quotedArgs = invocation.args + .map((arg) => `"${arg.replaceAll('"', '""')}"`) + .join(" "); + writeFileSync( + cmdPath, + `@echo off\r\n${quotedCommand} ${quotedArgs} %*\r\n`, + ); + return shimDir; + } + + const shimPath = path.join(shimDir, "letta"); + const commandWithArgs = [invocation.command, ...invocation.args] + .map(shellEscape) + .join(" "); + writeFileSync(shimPath, `#!/bin/sh\nexec ${commandWithArgs} "$@"\n`, { + mode: 0o755, + }); + return shimDir; +} + /** * Get enhanced environment variables for shell execution. * Includes bundled tools (like ripgrep) in PATH and Letta context for skill scripts. */ export function getShellEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; + const pathKey = + Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH"; + const pathPrefixes: string[] = []; + + const lettaInvocation = resolveLettaInvocation(env); + if (lettaInvocation) { + env.LETTA_CODE_BIN = lettaInvocation.command; + env[LETTA_BIN_ARGS_ENV] = JSON.stringify(lettaInvocation.args); + const shimDir = ensureLettaShimDir(lettaInvocation); + if (shimDir) { + pathPrefixes.push(shimDir); + } + } // Add ripgrep bin directory to PATH if available const rgBinDir = getRipgrepBinDir(); if (rgBinDir) { - // Windows uses "Path" (not "PATH"), and env vars are case-insensitive there. - // Find the actual key to avoid clobbering the user's PATH. - const pathKey = - Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH"; - env[pathKey] = `${rgBinDir}${path.delimiter}${env[pathKey] || ""}`; + pathPrefixes.push(rgBinDir); + } + + if (pathPrefixes.length > 0) { + const existingPath = env[pathKey] || ""; + env[pathKey] = existingPath + ? `${pathPrefixes.join(path.delimiter)}${path.delimiter}${existingPath}` + : pathPrefixes.join(path.delimiter); } // Add Letta context for skill scripts