fix: pin nested letta resolution in dev subagent shells (#937)
This commit is contained in:
123
src/tests/tools/shell-env.test.ts
Normal file
123
src/tests/tools/shell-env.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user