Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/commands/launch.parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@ describe("shouldOpenMcpPicker", () => {
// from the CUE_SMART_SUBSET env fold of a `-p` prompt (cached across launches).
describe("parse: subset origin", () => {
const prev = process.env.CUE_SMART_SUBSET;
const prevBypass = process.env.CUE_BYPASS;
afterEach(() => {
if (prev === undefined) delete process.env.CUE_SMART_SUBSET;
else process.env.CUE_SMART_SUBSET = prev;
if (prevBypass === undefined) delete process.env.CUE_BYPASS;
else process.env.CUE_BYPASS = prevBypass;
});

test("explicit --subset sets subsetExplicit", () => {
Expand All @@ -84,6 +87,27 @@ describe("parse: subset origin", () => {
expect(p.subset).toBeNull();
expect(p.subsetExplicit).toBe(false);
});

// Recursion guard. The classifier spawns `claude` by name, which on a machine
// with cue's shims first on PATH re-enters `cue launch`. With CUE_SMART_SUBSET
// exported globally, the fold below would turn that child's own argv into the
// next classification prompt and spawn another classifier — each level a full
// ~400MB claude process carrying the previous level's argv. Observed in the
// wild: 10 nested levels, a 67KB command line, ~3GB resident.
test("CUE_BYPASS suppresses the env fold so classifier spawns cannot recurse", () => {
process.env.CUE_SMART_SUBSET = "1";
process.env.CUE_BYPASS = "1";
const p = parse(["claude", "--print", "--model", "haiku", "-p", "which skills?"]);
expect(p.subset).toBeNull();
expect(p.subsetExplicit).toBe(false);
});

test("CUE_BYPASS does not block an explicit --subset", () => {
process.env.CUE_BYPASS = "1";
const p = parse(["claude", "--subset", "fix the parser"]);
expect(p.subset).toBe("fix the parser");
expect(p.subsetExplicit).toBe(true);
});
});

describe("isAlwaysPickEnabled", () => {
Expand Down
12 changes: 11 additions & 1 deletion src/commands/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,17 @@ function parse(args: string[]): ParsedArgs {
// Folds a real passthrough prompt (`claude -p "…"`) into `subset` so it drives
// classification, but leaves `subsetExplicit` false so it uses the keep-set
// cache — repeat identical `-p` launches don't re-call the classifier.
if (!subset && process.env.CUE_SMART_SUBSET && passthrough.length > 0) {
//
// CUE_BYPASS gates the fold: the skill/profile classifiers spawn `claude`
// themselves, and on a machine with cue's shims first on PATH that lands back
// here. Without this guard the child's own argv (`--print --model haiku -p
// "<prompt>"`) becomes the next classification prompt, which spawns another
// classifier, which folds ITS argv, and so on — each level a full ~400MB
// claude process carrying every previous level's argv. Measured in the wild:
// 10 levels deep, a 67KB command line, ~3GB resident per launch. An explicit
// `--subset` still wins, so a deliberate override is never silently dropped.
const bypassed = process.env.CUE_BYPASS === "1";
if (!subset && !bypassed && process.env.CUE_SMART_SUBSET && passthrough.length > 0) {
subset = passthrough.join(" ");
}
return { agent, override, forcePick, forcePickMcps, fullLoad, disableMcp, dryRun, rematerialize, subset, subsetExplicit, passthrough };
Expand Down
65 changes: 65 additions & 0 deletions src/lib/claude-classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";

import {
classifierBinOrder,
classifierSpawnArgs,
credExpiresAt,
setupClassifierHome,
Expand Down Expand Up @@ -173,3 +174,67 @@ describe("setupClassifierHome", () => {
}
});
});

// The classifier used to spawn the bare name `claude`, trusting CUE_BYPASS to
// make cue's shim "transparent". It never was: CUE_BYPASS is only read by the
// launch loader (spinner suppression), so the shim re-entered `cue launch`,
// which folded the child's own argv into a fresh classification prompt and
// spawned another classifier. Resolving the real binary FIRST breaks the loop
// at the source and skips a whole `cue launch` boot per classification.
describe("classifierBinOrder", () => {
const prevPath = process.env.PATH;
const prevReal = process.env.CUE_REAL_CLAUDE;
const prevExec = process.env.CLAUDE_CODE_EXECPATH;
afterEach(() => {
process.env.PATH = prevPath;
if (prevReal === undefined) delete process.env.CUE_REAL_CLAUDE;
else process.env.CUE_REAL_CLAUDE = prevReal;
if (prevExec === undefined) delete process.env.CLAUDE_CODE_EXECPATH;
else process.env.CLAUDE_CODE_EXECPATH = prevExec;
});

test("puts the real binary ahead of the bare PATH name", () => {
const realDir = tmpDir();
const realBin = join(realDir, "claude");
writeFileSync(realBin, "#!/usr/bin/env bash\necho real\n", { mode: 0o755 });
delete process.env.CUE_REAL_CLAUDE;
delete process.env.CLAUDE_CODE_EXECPATH;
process.env.PATH = realDir;

const order = classifierBinOrder();
expect(order[0]).toBe(realBin);
expect(order).not.toHaveLength(0);
});

test("skips a cue shim sitting earlier on PATH", () => {
const shimHome = tmpDir();
const realDir = tmpDir();
const shimBin = join(shimHome, "claude");
// Same body cue actually installs — the absolute-path `exec` form.
writeFileSync(shimBin, '#!/usr/bin/env bash\nexec "/opt/cue/bin/cue" launch claude "$@"\n', { mode: 0o755 });
const realBin = join(realDir, "claude");
writeFileSync(realBin, "#!/usr/bin/env bash\necho real\n", { mode: 0o755 });
delete process.env.CUE_REAL_CLAUDE;
delete process.env.CLAUDE_CODE_EXECPATH;
process.env.PATH = `${shimHome}:${realDir}`;

expect(classifierBinOrder()[0]).toBe(realBin);
});

test("still falls back to the bare name when no real binary resolves", () => {
delete process.env.CUE_REAL_CLAUDE;
delete process.env.CLAUDE_CODE_EXECPATH;
process.env.PATH = tmpDir(); // empty dir — nothing named claude
expect(classifierBinOrder()).toEqual(["claude"]);
});

test("never returns duplicates", () => {
const realDir = tmpDir();
writeFileSync(join(realDir, "claude"), "#!/usr/bin/env bash\n", { mode: 0o755 });
delete process.env.CUE_REAL_CLAUDE;
delete process.env.CLAUDE_CODE_EXECPATH;
process.env.PATH = realDir;
const order = classifierBinOrder();
expect(new Set(order).size).toBe(order.length);
});
});
43 changes: 32 additions & 11 deletions src/lib/claude-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,26 +210,47 @@ export interface ClassifierResult {
output: string;
}

/**
* Binaries to try for a classification spawn, best first.
*
* The real binary leads, deliberately. This used to spawn the bare name
* `claude` and trust `CUE_BYPASS` to make cue's shim "transparent" — it never
* did: `CUE_BYPASS` is read only by the launch loader, where it suppresses the
* spinner. So on any machine with cue's shims first on PATH (the default —
* `rcSnippet` pins them with `fish_add_path -p`) the spawn re-entered
* `cue launch`, which folded the child's argv into a new classification prompt
* and spawned another classifier. Measured before this change: 10 nested
* levels, a 67KB command line, ~3GB resident for one launch.
*
* `launch.ts` now also refuses the argv fold under `CUE_BYPASS`, so the loop is
* cut on both sides. Going straight to the real binary is still the better
* primary path — it skips a whole `cue launch` boot per classification.
*
* The bare name stays as a fallback for the case `findRealClaudeBin()` cannot
* resolve anything (unusual PATH, or a machine where only a shim exists).
*/
export function classifierBinOrder(): string[] {
const real = findRealClaudeBin();
return real ? [real, "claude"] : ["claude"];
}

/**
* Run one classification. Resolves `{ ok: false }` on every failure path.
*
* Tries the `claude` on PATH first (usually cue's own shim, which `CUE_BYPASS`
* makes transparent), then the resolved real binary. The fallback shares what's
* left of the budget — a minimum of 2s — so a double timeout can't stack to
* roughly twice the stated tolerance and freeze an interactive command.
* Tries {@link classifierBinOrder} in order. Later attempts share what's left
* of the budget — a minimum of 2s each — so repeated timeouts can't stack to
* several times the stated tolerance and freeze an interactive command.
*/
export async function runClassifier(prompt: string, timeoutMs = 30_000): Promise<ClassifierResult> {
const startedAt = Date.now();
const home = setupClassifierHome();
const configDir = home?.home;
try {
let res = await spawnClaude("claude", prompt, timeoutMs, configDir);
if (res.status !== 0 || !res.stdout.trim()) {
const fallback = findRealClaudeBin();
if (fallback) {
const remaining = Math.max(2_000, timeoutMs - (Date.now() - startedAt));
res = await spawnClaude(fallback, prompt, remaining, configDir);
}
let res = { status: 1, stdout: "" };
for (const bin of classifierBinOrder()) {
const remaining = Math.max(2_000, timeoutMs - (Date.now() - startedAt));
res = await spawnClaude(bin, prompt, remaining, configDir);
if (res.status === 0 && res.stdout.trim()) break;
}
if (res.status !== 0 || !res.stdout.trim()) return { ok: false, output: "" };
return { ok: true, output: res.stdout.trim() };
Expand Down
Loading