open-design/apps/daemon/tests/runtimes/executables.test.ts
Denis Redozubov f4c5d22f22
fix(daemon): confine sandbox project roots and host discovery (#3243)
* fix(daemon): confine sandbox project and host discovery

* fix(daemon): resolve sandbox data dir for toolchain discovery

* fix(daemon): resolve sandbox data dir for agent env

* fix(daemon): fail fast for sandbox imported folders

* test(daemon): assert sandbox imported folder rejection

* fix(daemon): keep sandbox import guard at run start

* fix(daemon): reject sandbox imported project file roots

* fix(daemon): preserve imported project detail roots

* test(daemon): expect sandbox profiles to stay scoped

* fix(daemon): bypass proxies for agent tool callbacks

* test(daemon): isolate media policy route memory extraction

* fix(daemon): keep loopback no-proxy scoped to sandbox
2026-05-30 16:57:04 +00:00

508 lines
19 KiB
TypeScript

import { test } from 'vitest';
import { relative, resolve } from 'node:path';
import {
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
const fsTest = process.platform === 'win32' ? test.skip : test;
// ---- OpenClaude fallback (issue #235) -------------------------------------
// OpenClaude (https://github.com/Gitlawb/openclaude) is a Claude Code fork
// that ships under a different binary name but speaks an argv-compatible
// CLI. Users with only `openclaude` on PATH should be auto-detected as the
// Claude Code agent without writing a wrapper script. The mechanism is the
// `fallbackBins` array on the Claude AGENT_DEF, consumed by
// `resolveAgentExecutable`.
test('claude entry declares openclaude as a fallback bin (issue #235)', () => {
assert.ok(
Array.isArray(claude.fallbackBins),
'claude.fallbackBins must be an array',
);
assert.ok(
claude.fallbackBins.includes('openclaude'),
`claude.fallbackBins must include 'openclaude'; got ${JSON.stringify(claude.fallbackBins)}`,
);
});
test('deepseek entry declares codewhale as a fallback bin (issue #2983)', () => {
assert.ok(
Array.isArray(deepseek.fallbackBins),
'deepseek.fallbackBins must be an array',
);
assert.ok(
deepseek.fallbackBins.includes('codewhale'),
`deepseek.fallbackBins must include 'codewhale'; got ${JSON.stringify(deepseek.fallbackBins)}`,
);
});
// resolveAgentExecutable touches the filesystem via existsSync; on
// Windows resolveOnPath also walks PATHEXT extensions, which our fixture
// files don't carry. Skip the filesystem-backed cases there — the
// declarative `fallbackBins`-on-claude assertion above still runs on
// every platform and is what catches regressions in the AGENT_DEF.
fsTest(
'resolveAgentExecutable uses packaged built-in Vela for AMR with the bundled OpenCode companion tree',
() => {
const root = mkdtempSync(join(tmpdir(), 'od-amr-built-in-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
const companionTree = join(resourceRoot, 'bin', 'libexec', 'opencode');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
mkdirSync(companionTree, { recursive: true });
writeFileSync(builtInVela, '#!/bin/sh\nexit 0\n');
chmodSync(builtInVela, 0o755);
// Match the resources.test.ts packaging contract: the companion tree
// is only valid when `<libexec>/opencode/opencode` actually exists +
// is executable. Directory-only checks were producing a false-positive
// availability path.
const companionExe = join(companionTree, 'opencode');
writeFileSync(companionExe, '#!/bin/sh\nexit 0\n');
chmodSync(companionExe, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const resolved = resolveAgentExecutable(minimalAgentDef({ id: 'amr', bin: 'vela' }));
assert.equal(resolved, builtInVela);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable does not select packaged built-in Vela when OpenCode is missing',
() => {
const root = mkdtempSync(join(tmpdir(), 'od-amr-built-in-no-opencode-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
writeFileSync(builtInVela, '#!/bin/sh\nexit 0\n');
chmodSync(builtInVela, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const resolved = resolveAgentExecutable(minimalAgentDef({ id: 'amr', bin: 'vela' }));
assert.equal(resolved, null);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable prefers configured VELA_BIN over packaged built-in Vela',
() => {
const root = mkdtempSync(join(tmpdir(), 'od-amr-built-in-precedence-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT'], () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
const configuredVela = join(root, 'configured', 'vela');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
mkdirSync(join(root, 'configured'), { recursive: true });
writeFileSync(builtInVela, '#!/bin/sh\nexit 0\n');
writeFileSync(configuredVela, '#!/bin/sh\nexit 0\n');
chmodSync(builtInVela, 0o755);
chmodSync(configuredVela, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
const resolved = resolveAgentExecutable(
minimalAgentDef({ id: 'amr', bin: 'vela' }),
{ VELA_BIN: configuredVela },
);
assert.equal(resolved, configuredVela);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable falls back to PATH Vela when packaged built-in Vela is absent',
() => {
const root = mkdtempSync(join(tmpdir(), 'od-amr-path-fallback-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT'], () => {
const pathBin = join(root, 'path-bin');
const pathVela = join(pathBin, 'vela');
mkdirSync(pathBin, { recursive: true });
writeFileSync(pathVela, '#!/bin/sh\nexit 0\n');
chmodSync(pathVela, 0o755);
process.env.PATH = pathBin;
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = join(root, 'resources', 'open-design');
const resolved = resolveAgentExecutable(minimalAgentDef({ id: 'amr', bin: 'vela' }));
assert.equal(resolved, pathVela);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable prefers def.bin over fallbackBins when bin is on PATH',
() => {
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
try {
writeFileSync(join(dir, 'claude'), '');
writeFileSync(join(dir, 'openclaude'), '');
chmodSync(join(dir, 'claude'), 0o755);
chmodSync(join(dir, 'openclaude'), 0o755);
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable(minimalAgentDef({
bin: 'claude',
fallbackBins: ['openclaude'],
}));
assert.equal(resolved, join(dir, 'claude'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable falls back through fallbackBins when def.bin is missing',
() => {
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
try {
// Only `openclaude` is installed (Claude Code fork-only setup).
writeFileSync(join(dir, 'openclaude'), '');
chmodSync(join(dir, 'openclaude'), 0o755);
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable(minimalAgentDef({
bin: 'claude',
fallbackBins: ['openclaude'],
}));
assert.equal(resolved, join(dir, 'openclaude'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable returns null when neither def.bin nor any fallback is on PATH',
() => {
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
try {
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable(minimalAgentDef({
bin: 'claude',
fallbackBins: ['openclaude'],
}));
assert.equal(resolved, null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable searches mise node bins when PATH is minimal',
() => {
const home = mkdtempSync(join(tmpdir(), 'od-agents-home-'));
try {
const dir = join(
home,
'.local',
'share',
'mise',
'installs',
'node',
'24.14.1',
'bin',
);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'codex'), '');
chmodSync(join(dir, 'codex'), 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const resolved = resolveAgentExecutable(minimalAgentDef({
bin: 'codex',
}));
assert.equal(resolved, join(dir, 'codex'));
} finally {
rmSync(home, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable still resolves agents without a fallbackBins field',
() => {
// Guard against a regression that would require every AGENT_DEF to
// declare fallbackBins. Most agents (codex / gemini / opencode / ...)
// only have a single binary name and must keep working unchanged.
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
try {
writeFileSync(join(dir, 'codex'), '');
chmodSync(join(dir, 'codex'), 0o755);
process.env.PATH = dir;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'codex' }));
assert.equal(resolved, join(dir, 'codex'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
// Issue #442: GUI-launched daemons (Finder/Dock on macOS, .desktop on Linux)
// inherit a stripped PATH that doesn't include the user's npm global prefix.
// Most third-party "fix npm EACCES without sudo" tutorials configure
// `~/.npm-global` as the prefix, so any CLI installed via `npm i -g <cli>`
// lives at `~/.npm-global/bin/<cli>`. The daemon must search there even when
// the inherited PATH only carries `/usr/bin:/bin:...`.
fsTest(
'resolveAgentExecutable searches ~/.npm-global/bin under a minimal GUI-launched PATH (issue #442)',
() => {
const home = mkdtempSync(join(tmpdir(), 'od-agents-npm-global-'));
try {
const dir = join(home, '.npm-global', 'bin');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'gemini'), '');
chmodSync(join(dir, 'gemini'), 0o755);
process.env.OD_AGENT_HOME = home;
// Mirror the launchd default a `.app` actually inherits — no
// `~/.npm-global/bin`, no `/opt/homebrew/bin`, nothing user-side.
process.env.PATH = '/usr/bin:/bin';
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(resolved, join(dir, 'gemini'));
} finally {
rmSync(home, { recursive: true, force: true });
}
},
);
// Same root cause as #442 but for the second-most-common alternative
// non-canonical npm prefix shipped in older "fix sudo-free npm" guides.
fsTest(
'resolveAgentExecutable also searches ~/.npm-packages/bin (alt npm prefix)',
() => {
const home = mkdtempSync(join(tmpdir(), 'od-agents-npm-packages-'));
try {
const dir = join(home, '.npm-packages', 'bin');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'gemini'), '');
chmodSync(join(dir, 'gemini'), 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(resolved, join(dir, 'gemini'));
} finally {
rmSync(home, { recursive: true, force: true });
}
},
);
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(minimalAgentDef({ 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(minimalAgentDef({ 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 <prefix>/bin into the
// sandboxed search list. Otherwise an agent installed by the host
// machine could satisfy a "not on PATH" assertion in the sandbox and
// make detection tests environment-dependent. Raised in PR review on
// #442 (review comment by @mrcfps on apps/daemon/src/agents.ts:742).
fsTest(
'OD_AGENT_HOME isolates resolution from $NPM_CONFIG_PREFIX leakage',
() => {
const sandbox = mkdtempSync(join(tmpdir(), 'od-agents-sandbox-'));
const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-'));
const realPrefixBin = join(realPrefix, 'bin');
try {
// Sandbox is empty — gemini does not exist under OD_AGENT_HOME.
// Real prefix has a gemini, simulating the developer's /opt/...
// or ~/.npm-global install. NPM_CONFIG_PREFIX points at it.
mkdirSync(realPrefixBin, { recursive: true });
writeFileSync(join(realPrefixBin, 'gemini'), '');
chmodSync(join(realPrefixBin, 'gemini'), 0o755);
process.env.OD_AGENT_HOME = sandbox;
process.env.PATH = '/usr/bin:/bin';
process.env.NPM_CONFIG_PREFIX = realPrefix;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(
resolved,
null,
`OD_AGENT_HOME sandbox must not see the real $NPM_CONFIG_PREFIX bin; ` +
`got ${resolved}`,
);
} finally {
// afterEach restores NPM_CONFIG_PREFIX to its pre-test value (or
// deletes it when it was unset), so do not unconditionally
// `delete` it here — that would clobber an export the developer
// / CI runner had already set, leaking into the next test in the
// same Vitest worker.
rmSync(sandbox, { recursive: true, force: true });
rmSync(realPrefix, { recursive: true, force: true });
}
},
);
fsTest(
'OD_SANDBOX_MODE scopes fallback toolchain discovery to OD_DATA_DIR',
() => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agents-sandbox-data-'));
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-'));
const realPrefixBin = join(realPrefix, 'bin');
try {
return withEnvSnapshot(
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
() => {
mkdirSync(realPrefixBin, { recursive: true });
writeFileSync(join(realPrefixBin, 'gemini'), '');
chmodSync(join(realPrefixBin, 'gemini'), 0o755);
delete process.env.OD_AGENT_HOME;
process.env.OD_DATA_DIR = dataDir;
process.env.OD_SANDBOX_MODE = '1';
process.env.PATH = emptyPath;
process.env.NPM_CONFIG_PREFIX = realPrefix;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(
resolved,
null,
`sandbox mode must not see the host $NPM_CONFIG_PREFIX bin; got ${resolved}`,
);
},
);
} finally {
rmSync(dataDir, { recursive: true, force: true });
rmSync(emptyPath, { recursive: true, force: true });
rmSync(realPrefix, { recursive: true, force: true });
}
},
);
fsTest(
'OD_SANDBOX_MODE resolves relative OD_DATA_DIR before fallback toolchain discovery',
() => {
const projectRoot = resolve(process.cwd(), '../..');
const parent = mkdtempSync(join(tmpdir(), 'od-agents-relative-data-parent-'));
const dataDir = join(parent, 'data');
const sandboxBin = join(dataDir, 'sandbox', 'agent-home', '.local', 'bin');
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
try {
return withEnvSnapshot(
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
() => {
mkdirSync(sandboxBin, { recursive: true });
const geminiPath = join(sandboxBin, 'gemini');
writeFileSync(geminiPath, '');
chmodSync(geminiPath, 0o755);
delete process.env.OD_AGENT_HOME;
delete process.env.NPM_CONFIG_PREFIX;
process.env.OD_DATA_DIR = relative(projectRoot, dataDir);
process.env.OD_SANDBOX_MODE = '1';
process.env.PATH = emptyPath;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(resolved, geminiPath);
},
);
} finally {
rmSync(parent, { recursive: true, force: true });
rmSync(emptyPath, { recursive: true, force: true });
}
},
);
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(minimalAgentDef({ 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 });
}
},
);