Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
88 changes: 80 additions & 8 deletions apps/daemon/src/runtimes/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export interface DetectedRuntimeVersions {
// exact executable family without spawning another process on every turn.
const detectedRuntimeVersions = new Map<string, DetectedRuntimeVersions>();

// How many unusable binaries detection will walk past before giving up on an
// agent. Each attempt costs one bounded `--version` spawn, and the healthy
// case stops at the first candidate, so this only bounds the pathological
// shape: the same CLI name shadowed in many search directories at once.
const MAX_EXECUTABLE_ATTEMPTS = 8;

export function getDetectedRuntimeVersions(
agentId: string | null | undefined,
): DetectedRuntimeVersions | null {
Expand Down Expand Up @@ -256,11 +262,23 @@ async function probe(
// If detection probes the shim but chat/run spawns the native binary, the
// UI incorrectly reports "not installed" until the user pins CODEX_BIN by
// hand even though the real launch path is healthy.
const launch = resolveAgentLaunch(def, configuredEnv);
if (!launch.selectedPath || !launch.launchPath) {
const initialLaunch = resolveAgentLaunch(def, configuredEnv);
if (!initialLaunch.selectedPath || !initialLaunch.launchPath) {
return unavailableAgent(def, [buildExecutableDiagnostic(def, configuredEnv)]);
}
const probeEnv = applyAgentLaunchEnv(
// Carry the narrowed pair explicitly: the candidate walk below reassigns
// this binding, which would otherwise discard the null-check above and
// force every downstream reader to re-prove the paths are present.
type ProbedLaunch = ReturnType<typeof resolveAgentLaunch> & {
selectedPath: string;
launchPath: string;
};
let launch: ProbedLaunch = {
...initialLaunch,
selectedPath: initialLaunch.selectedPath,
launchPath: initialLaunch.launchPath,
};
let probeEnv = applyAgentLaunchEnv(
spawnEnvForAgent(
def.id,
{
Expand All @@ -273,14 +291,68 @@ async function probe(
),
launch,
);
const outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
let outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
// Resolving a name on PATH only proves a file exists there, never that it
// runs. A directory that ranks earlier in the search order can hold a
// wrapper orphaned by a half-finished `npm i -g` — the shim survives, the
// package it points at does not — and stopping at that first hit hides a
// perfectly good CLI of the same name further down the list. Walk past
// every candidate that cannot be executed before declaring the agent
// unusable. Only spawn-level failures advance the walk: a version that
// parses badly, or a binary that runs and exits non-zero, is a real
// answer from the right binary and must not fall through to another one.
const attemptedPaths: string[] = [];
while (
outcome.kind === 'not-invocable' &&
attemptedPaths.length < MAX_EXECUTABLE_ATTEMPTS
) {
const failedPath = launch.selectedPath;
if (!failedPath) break;
attemptedPaths.push(failedPath);
const next = resolveAgentLaunch(def, configuredEnv, {
skipPathCandidates: attemptedPaths,
});
// No candidate left, or the resolver handed back something already
// proven broken (an explicit override or packaged built-in, which are
// deliberately not skippable) — either way there is nothing new to try.
if (!next.selectedPath || !next.launchPath) break;
if (attemptedPaths.includes(next.selectedPath)) break;
launch = {
...next,
selectedPath: next.selectedPath,
launchPath: next.launchPath,
};
probeEnv = applyAgentLaunchEnv(
spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
undefined,
{ resolvedBin: next.selectedPath },
),
next,
);
outcome = await probeVersionAtPath(def, next.launchPath, probeEnv);
}
Comment thread
lefarcen marked this conversation as resolved.
if (outcome.kind === 'not-invocable') {
return unavailableAgent(def, [
buildNotInvocableDiagnostic(def, launch, outcome.cause),
]);
// Report the path that was actually tried. The agent picker only renders
// an unavailable agent when it carries a path (that is what makes the
// row actionable), so dropping it here erases the agent from the UI and
// leaves the user with no way to see or fix what went wrong.
return unavailableAgent(
def,
[buildNotInvocableDiagnostic(def, launch, outcome.cause)],
{ path: launch.selectedPath },
);
}
if (def.versionPolicy?.requireVersion && !outcome.version) {
return unavailableAgent(def, [buildVersionDiagnostic(def, outcome.version)]);
return unavailableAgent(def, [buildVersionDiagnostic(def, outcome.version)], {
path: launch.selectedPath,
version: outcome.version,
});
}
let runtimeCompanionVersion: string | undefined;
if (def.compatibilityProbe) {
Expand Down
38 changes: 31 additions & 7 deletions apps/daemon/src/runtimes/executables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,19 +128,36 @@ export function agentBinEnvKey(agentId: string | undefined): string | null {
return AGENT_BIN_ENV_KEYS.get(agentId) ?? null;
}

export function resolveOnPath(bin: string): string | null {
// Every file named `bin` that exists on the search path, in resolution
// order. Detection needs the whole list, not just the winner: a directory
// that ranks earlier can hold a wrapper left behind by a half-finished
// install, and executing it fails even though a working CLI of the same
// name sits in a later directory. Resolution alone cannot tell the two
// apart — only spawning can — so the caller walks candidates until one
// actually runs.
export function resolveAllOnPath(bin: string): string[] {
const exts =
process.platform === 'win32'
? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
: [''];
const dirs = resolvePathDirs();
const found: string[] = [];
const seen = new Set<string>();
for (const dir of dirs) {
for (const ext of exts) {
const full = path.join(dir, bin + ext);
if (full && existsSync(full)) return full;
if (!full || seen.has(full)) continue;
if (existsSync(full)) {
seen.add(full);
found.push(full);
}
}
}
return null;
return found;
}

export function resolveOnPath(bin: string): string | null {
return resolveAllOnPath(bin)[0] ?? null;
}

function looksExecutableOnWindows(filePath: string): boolean {
Expand Down Expand Up @@ -339,6 +356,12 @@ export function resolveAgentExecutable(
export function inspectAgentExecutableResolution(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
// Paths already proven unusable by a spawn attempt. Only PATH-derived
// candidates are skippable: an explicit `*_BIN` override, a packaged
// built-in, and the Codex app bundle are deliberate selections, so a
// broken one must surface as an error rather than silently resolving to
// some other binary the user never pointed at.
options: { skipPathCandidates?: readonly string[] } = {},
): {
configuredOverridePath: string | null;
pathResolvedPath: string | null;
Expand All @@ -356,12 +379,13 @@ export function inspectAgentExecutableResolution(
def.bin,
...(Array.isArray(def.fallbackBins) ? def.fallbackBins : []),
];
const skip = new Set(options.skipPathCandidates ?? []);
let pathResolvedPath: string | null = null;
for (const bin of candidates) {
const resolved = resolveOnPath(bin);
if (resolved) {
outer: for (const bin of candidates) {
for (const resolved of resolveAllOnPath(bin)) {
if (skip.has(resolved)) continue;
pathResolvedPath = resolved;
break;
break outer;
}
}
const builtInPath = packagedBuiltInExecutable(def, configuredEnv);
Expand Down
3 changes: 2 additions & 1 deletion apps/daemon/src/runtimes/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ export type AgentLaunchResolution = ReturnType<typeof inspectAgentExecutableReso
export function resolveAgentLaunch(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
options: { skipPathCandidates?: readonly string[] } = {},
): AgentLaunchResolution {
const resolution = inspectAgentExecutableResolution(def, configuredEnv);
const resolution = inspectAgentExecutableResolution(def, configuredEnv, options);
if (!resolution.selectedPath) {
return { ...resolution, launchPath: null, launchKind: 'selected', childPathPrepend: [], diagnostic: null };
}
Expand Down
110 changes: 110 additions & 0 deletions apps/daemon/tests/runtimes/executable-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { detectAgent } from '../../src/runtimes/detection.js';
import type { RuntimeAgentDef } from '../../src/runtimes/types.js';

// A minimal agent def: no compatibility probe, so these cases isolate the
// binary-resolution stage from the profile handshake that `deepseek-harness`
// layers on top of it.
const def: RuntimeAgentDef = {
id: 'deepseek-harness',
name: 'DeepSeek Harness',
bin: 'dsh',
versionArgs: ['--version'],
fallbackModels: [{ id: 'default', label: 'Default' }],
buildArgs: () => [],
streamFormat: 'dsh-profile-jsonl',
};

/**
* A shim that resolves on PATH but cannot be executed: the interpreter its
* shebang names does not exist, so the spawn fails with ENOENT. This is the
* shape a half-finished `npm i -g` leaves behind — the wrapper survives, the
* package it points at does not.
*/
function writeBrokenShim(dir: string): string {
const bin = path.join(dir, 'dsh');
writeFileSync(bin, '#!/nonexistent/interpreter\n');
chmodSync(bin, 0o755);
return bin;
}

function writeWorkingShim(dir: string, version = '0.1.0-rc.6'): string {
const bin = path.join(dir, 'dsh');
writeFileSync(bin, `#!/bin/sh\nprintf '%s\\n' '${version}'\n`);
chmodSync(bin, 0o755);
return bin;
}

describe('agent executable resolution falls back past unusable candidates', () => {
const dirs: string[] = [];
let savedPath: string | undefined;
let savedAgentHome: string | undefined;
let savedDshBin: string | undefined;

beforeEach(() => {
savedPath = process.env.PATH;
savedAgentHome = process.env.OD_AGENT_HOME;
savedDshBin = process.env.DSH_BIN;
delete process.env.DSH_BIN;
});

afterEach(() => {
if (savedPath === undefined) delete process.env.PATH;
else process.env.PATH = savedPath;
if (savedAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = savedAgentHome;
if (savedDshBin === undefined) delete process.env.DSH_BIN;
else process.env.DSH_BIN = savedDshBin;
while (dirs.length > 0) {
rmSync(dirs.pop() as string, { recursive: true, force: true });
}
});

function tempDir(label: string): string {
const dir = mkdtempSync(path.join(tmpdir(), `od-exec-fallback-${label}-`));
dirs.push(dir);
return dir;
}

// The production report behind this suite: a stale `npm i -g` wrapper sat in
// a directory that OpenDesign searches *before* the one the official
// installer writes to, so the working CLI was never reached and the agent
// vanished from the picker entirely.
it('reaches a working binary that sits behind a broken one on PATH', async () => {
if (process.platform === 'win32') return;
const brokenDir = tempDir('broken');
const goodDir = tempDir('good');
writeBrokenShim(brokenDir);
const goodBin = writeWorkingShim(goodDir);

process.env.OD_AGENT_HOME = goodDir;
process.env.PATH = [brokenDir, goodDir].join(path.delimiter);

const detected = await detectAgent(def);

expect(detected.available).toBe(true);
expect(detected.path).toBe(goodBin);
expect(detected.version).toBe('0.1.0-rc.6');
});

// Even when every candidate is unusable, detection must surface the path it
// actually tried. The picker hides an agent that reports no path at all, so
// dropping it leaves the user with an invisible agent and no way to act.
it('keeps the attempted path when no candidate can be executed', async () => {
if (process.platform === 'win32') return;
const brokenDir = tempDir('only-broken');
const brokenBin = writeBrokenShim(brokenDir);

process.env.OD_AGENT_HOME = brokenDir;
process.env.PATH = brokenDir;

const detected = await detectAgent(def);

expect(detected.available).toBe(false);
expect(detected.path).toBe(brokenBin);
expect(detected.diagnostics?.[0]?.reason).toBe('shim-broken');
});
});
18 changes: 16 additions & 2 deletions apps/landing-page/public/install-dsh.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,22 @@ $ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

$NodeVersion = '24.19.0'
$DshVersion = '0.1.0-rc.6'
$DshVersion = '0.1.0-rc.8'
$PnpmVersion = '11.7.0'
# Freeze npm's view of the registry to just after $DshVersion was published.
#
# Every @deepseek-ai/dsh-* package declares its ~190 siblings with a caret
# range (^0.1.0-rc.N). npm reads a caret whose floor carries a prerelease tag
# as "this prerelease or any newer version", so pinning the top-level package
# alone lets the entire tree float onto whichever release candidate is newest.
# The generations are mutually exclusive — an rc.8 package peer-requires
# rc.8 siblings — so a mixed tree sends npm into an ERESOLVE backtrack across
# a combinatorial search space that never converges: the install appears to
# hang while scrolling warnings forever.
#
# The cutoff must stay LATER than $DshVersion's publish time and EARLIER than
# the next release candidate's. Update both values together.
$DshResolutionCutoff = '2026-08-19T16:00:00Z'

function Fail([string]$Message) {
throw "DeepSeek Harness installer: $Message"
Expand Down Expand Up @@ -151,7 +165,7 @@ try {
$runtimeStaging = Join-Path $InstallRoot ".runtime-dsh-$DshVersion.$PID"
New-Item -ItemType Directory -Force -Path $runtimeStaging | Out-Null
Write-Host "Installing dsh $DshVersion and pnpm $PnpmVersion in OpenDesign's user toolchain..."
& (Join-Path $NodeTarget 'npm.cmd') install --prefix $runtimeStaging --no-save --no-package-lock --omit=dev "@deepseek-ai/dsh@$DshVersion" "pnpm@$PnpmVersion"
& (Join-Path $NodeTarget 'npm.cmd') install --prefix $runtimeStaging --no-save --no-package-lock --omit=dev --before $DshResolutionCutoff "@deepseek-ai/dsh@$DshVersion" "pnpm@$PnpmVersion"
if ($LASTEXITCODE -ne 0) { Fail "npm install exited with code $LASTEXITCODE." }

$stagingBin = Join-Path $runtimeStaging 'node_modules\.bin'
Expand Down
17 changes: 16 additions & 1 deletion apps/landing-page/public/install-dsh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,22 @@
set -eu

NODE_VERSION='24.19.0'
DSH_VERSION='0.1.0-rc.6'
DSH_VERSION='0.1.0-rc.8'
PNPM_VERSION='11.7.0'
# Freeze npm's view of the registry to just after DSH_VERSION was published.
#
# Every @deepseek-ai/dsh-* package declares its ~190 siblings with a caret
# range (^0.1.0-rc.N). npm reads a caret whose floor carries a prerelease tag
# as "this prerelease or any newer version", so pinning the top-level package
# alone lets the entire tree float onto whichever release candidate is newest.
# The generations are mutually exclusive — an rc.8 package peer-requires
# rc.8 siblings — so a mixed tree sends npm into an ERESOLVE backtrack across
# a combinatorial search space that never converges: the install appears to
# hang while scrolling warnings forever.
#
# The cutoff must stay LATER than DSH_VERSION's publish time and EARLIER than
# the next release candidate's. Update both values together.
DSH_RESOLUTION_CUTOFF='2026-08-19T16:00:00Z'

NO_LAUNCH=0

Expand Down Expand Up @@ -228,6 +242,7 @@ PATH="$managed_node_dir/bin:${PATH:-}" "$managed_node_dir/bin/npm" install \
--no-save \
--no-package-lock \
--omit=dev \
--before "$DSH_RESOLUTION_CUTOFF" \
"@deepseek-ai/dsh@$DSH_VERSION" \
"pnpm@$PNPM_VERSION"

Expand Down
Loading
Loading