mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(amr): address review feedback on #2355
Targeted fixes for the unresolved review threads on #2355. Each fix includes / updates a focused test. - runtimes/executables.ts: `packagedVelaOpenCodeCompanionTree` now verifies the inner `opencode` executable exists + is runnable, not just the directory. This closes the false-positive availability path that let `detectAgents()` surface AMR as available even when the packaged companion was empty / partially copied (mrcfps, 4 threads). - runtimes/executables.ts: `resolveAmrOpenCodeExecutable` now prefers the bundled `<OD_RESOURCE_ROOT>/bin/libexec/opencode/opencode` over a stale `opencode` on the user's PATH, so packaged AMR builds can't be hijacked by a global installation. - web/EntryShell.tsx: when the Local CLI scan returns an available agent and the previously-selected agent is AMR, switch the selection to the first available local agent so the runtime and persisted agent agree before Continue. - server.ts (model-probe branch): for AMR, check `readVelaLoginStatus` BEFORE rejecting on an empty live-model catalog — a signed-out user was getting `AMR_MODEL_UNAVAILABLE` ("choose a model") instead of the correct `AMR_AUTH_REQUIRED` (sign-in affordance). - server.ts (default model fallback): if the user asked for the AMR agent default and the cached id is no longer in the FRESH catalog, fall back to `liveModels[0]` from the probe instead of rejecting the run as `AMR_MODEL_UNAVAILABLE`. - integrations/vela.ts: route `vela login` through `createCommandInvocation` so an npm/Node-style `vela.cmd` / `.bat` shim on Windows gets the correct `cmd.exe /d /s /c …` wrapping with verbatim args (matches `execAgentFile` / chat-run spawning). - tools/pack/src/linux.ts: in containerized Linux builds, bind-mount the host directory of `OPEN_DESIGN_VELA_CLI_BIN` and rewrite the env to the container-side path. The host path was being passed in as-is even though the default container only mounts /project, /tools-pack and cache/home — `copyOptionalVelaCliBinary` saw a missing path. Deferred (out of scope for this PR): - `od amr status/login/logout/cancel` CLI subcommands (AGENTS.md UI/CLI dual-track rule, server.ts:5763) — sizable surface; tracked for a separate focused PR. - Strict `--require-vela-cli` for Windows + mac-x64 beta builds: prematurely blocked — `@powerformer/vela-cli` only publishes the `darwin-arm64` platform binary today; adding the flag elsewhere would fail the builds. Revisit once win/x64/linux binaries ship.
This commit is contained in:
parent
d55d7882a9
commit
13fc4f4852
8 changed files with 128 additions and 7 deletions
|
|
@ -3,6 +3,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createCommandInvocation } from '@open-design/platform';
|
||||
|
||||
import { resolveAgentLaunch } from '../runtimes/launch.js';
|
||||
import { spawnEnvForAgent } from '../runtimes/env.js';
|
||||
import { getAgentDef } from '../runtimes/registry.js';
|
||||
|
|
@ -244,10 +246,17 @@ export async function spawnVelaLogin(
|
|||
throw new Error('vela binary not found; install vela or configure VELA_BIN');
|
||||
}
|
||||
const env = spawnEnvForAgent('amr', baseEnv, configuredEnv);
|
||||
const child = spawn(bin, ['login'], {
|
||||
// Route through createCommandInvocation so an npm/Node-style `vela.cmd` or
|
||||
// `vela.bat` shim on Windows gets wrapped under `cmd.exe /d /s /c …` with
|
||||
// verbatim args, matching what `execAgentFile` / chat-run spawning do. A
|
||||
// direct `spawn(bin, args)` on a `.cmd` shim quietly fails to find the
|
||||
// shim's actual entry point. POSIX is unchanged (no wrapping needed).
|
||||
const invocation = createCommandInvocation({ command: bin, args: ['login'], env });
|
||||
const child = spawn(invocation.command, invocation.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
detached: false,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
if (typeof child.pid !== 'number') {
|
||||
throw new Error('failed to spawn vela login');
|
||||
|
|
|
|||
|
|
@ -138,13 +138,49 @@ export function resolveAmrOpenCodeExecutable(
|
|||
): string | null {
|
||||
const configured = executableFilePath(env.VELA_OPENCODE_BIN);
|
||||
if (configured) return configured;
|
||||
// In packaged builds prefer the bundled companion under
|
||||
// `OD_RESOURCE_ROOT/bin/libexec/opencode/opencode` so a stale global
|
||||
// `opencode` on the user's PATH can't override the known-good build that
|
||||
// shipped with this app. PATH is only consulted as a last resort.
|
||||
const resourceRoot = (
|
||||
env.OD_RESOURCE_ROOT ?? process.env.OD_RESOURCE_ROOT
|
||||
)?.trim();
|
||||
if (resourceRoot) {
|
||||
const bundledDir = packagedVelaOpenCodeCompanionTree(resourceRoot);
|
||||
if (bundledDir) {
|
||||
const bundled = executableFilePath(
|
||||
path.join(
|
||||
bundledDir,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode',
|
||||
),
|
||||
);
|
||||
if (bundled) return bundled;
|
||||
}
|
||||
}
|
||||
return resolveOnPath('opencode-cli') ?? resolveOnPath('opencode');
|
||||
}
|
||||
|
||||
// `tools/pack/tests/resources.test.ts` ships the AMR OpenCode companion as a
|
||||
// `<resourceRoot>/bin/libexec/opencode/opencode` *executable file*, not just
|
||||
// the directory. Treating any directory there as a valid companion produces a
|
||||
// false-positive availability path: `detectAgents()` would surface AMR as
|
||||
// available even though the first real run can't launch (`vela` would spawn
|
||||
// a missing/non-executable inner binary). Verify the inner executable too.
|
||||
function packagedVelaOpenCodeCompanionTree(resourceRoot: string): string | null {
|
||||
const candidate = path.join(resourceRoot, 'bin', 'libexec', 'opencode');
|
||||
const exe = path.join(
|
||||
candidate,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode',
|
||||
);
|
||||
try {
|
||||
return statSync(candidate).isDirectory() ? candidate : null;
|
||||
if (!statSync(candidate).isDirectory()) return null;
|
||||
if (!statSync(exe).isFile()) return null;
|
||||
if (process.platform === 'win32') {
|
||||
if (!looksExecutableOnWindows(exe)) return null;
|
||||
} else {
|
||||
accessSync(exe, constants.X_OK);
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10873,12 +10873,47 @@ export async function startServer({
|
|||
liveModels.map((candidate) => candidate?.id).filter(Boolean),
|
||||
);
|
||||
if (liveModelIds.size === 0) {
|
||||
// An empty AMR catalog usually means the user is signed out — `vela
|
||||
// models` returns 401 and the catch above leaves `liveModels` empty.
|
||||
// Surface AMR_AUTH_REQUIRED first so the chat shows the relogin
|
||||
// affordance; otherwise the user sees a misleading "choose a model"
|
||||
// when the real fix is to sign in.
|
||||
if (def.id === 'amr') {
|
||||
const loginStatus = readVelaLoginStatus(
|
||||
modelProbeEnv ?? process.env,
|
||||
configuredAgentEnv,
|
||||
);
|
||||
if (!loginStatus.loggedIn) {
|
||||
sendAmrAccountFailure({
|
||||
code: 'AMR_AUTH_REQUIRED',
|
||||
message:
|
||||
'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.',
|
||||
action: 'relogin',
|
||||
});
|
||||
return design.runs.finish(run, 'failed', 1, null);
|
||||
}
|
||||
}
|
||||
send('error', createAmrModelUnavailablePayload(safeModel, {
|
||||
reason: 'model_catalog_unavailable',
|
||||
}));
|
||||
return design.runs.finish(run, 'failed', 1, null);
|
||||
}
|
||||
if (!safeModel || safeModel === 'default') {
|
||||
// `safeModel` was pre-resolved via the agent-wide cached model order,
|
||||
// so a request that came in as 'default' (or empty) is already a
|
||||
// concrete id by this point — `safeModel === 'default'` is rarely true.
|
||||
// If the user actually asked for the agent default and the cached id no
|
||||
// longer appears in the FRESH catalog (e.g. the AMR Link catalog rolled
|
||||
// since `/api/agents` last responded), fall back to `liveModels[0]` from
|
||||
// the fresh probe instead of rejecting their run as `AMR_MODEL_UNAVAILABLE`.
|
||||
const userAskedForDefault =
|
||||
typeof model !== 'string' ||
|
||||
!model.trim() ||
|
||||
model.trim().toLowerCase() === 'default';
|
||||
if (
|
||||
!safeModel ||
|
||||
safeModel === 'default' ||
|
||||
(userAskedForDefault && !liveModelIds.has(safeModel))
|
||||
) {
|
||||
safeModel = liveModels[0]?.id ?? null;
|
||||
agentOptions.model = safeModel;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -372,6 +372,13 @@ fsTest('detectAgents marks AMR available from packaged built-in Vela with the bu
|
|||
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "vela manual-amr"; exit 0; fi\nexit 0\n',
|
||||
);
|
||||
chmodSync(builtInVela, 0o755);
|
||||
// The companion tree is only "valid" when an actual `opencode`
|
||||
// executable lives inside — directory-only checks were treating an
|
||||
// empty/partial copy as available and the first real run had nothing
|
||||
// to launch. Match the resources.test.ts packaging contract.
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ fsTest(
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -1417,6 +1417,15 @@ function OnboardingView({
|
|||
const nextAgents = await onRefreshAgents();
|
||||
if (cliScanTokenRef.current !== scanToken) return;
|
||||
const availableAgents = nextAgents.filter((agent) => agent.available && agent.id !== 'amr');
|
||||
// If the user previously had AMR selected (e.g. it was auto-picked once
|
||||
// we detected vela) and they have now chosen the Local CLI path, the
|
||||
// persisted agentId is still 'amr' and would survive Continue without
|
||||
// an explicit click on a local agent card. Switch the selection to the
|
||||
// first available local agent as soon as we have one, so the runtime
|
||||
// and the persisted agent always agree.
|
||||
if (config.agentId === 'amr' && availableAgents[0]) {
|
||||
onAgentChange(availableAgents[0].id);
|
||||
}
|
||||
// Scan-result semantics: zero available CLIs is a `failed` outcome
|
||||
// because the user's runtime path is blocked, even though the
|
||||
// detect call itself returned successfully. `detected_cli_count`
|
||||
|
|
|
|||
|
|
@ -215,8 +215,19 @@ export function buildDockerArgs(
|
|||
if (config.telemetryRelayUrl != null) {
|
||||
dockerArgs.push("-e", `OPEN_DESIGN_TELEMETRY_RELAY_URL=${config.telemetryRelayUrl}`);
|
||||
}
|
||||
if (process.env.OPEN_DESIGN_VELA_CLI_BIN?.trim()) {
|
||||
dockerArgs.push("-e", `OPEN_DESIGN_VELA_CLI_BIN=${process.env.OPEN_DESIGN_VELA_CLI_BIN.trim()}`);
|
||||
const velaBinHost = process.env.OPEN_DESIGN_VELA_CLI_BIN?.trim();
|
||||
if (velaBinHost) {
|
||||
// The container only mounts /project, /tools-pack and cache/home dirs by
|
||||
// default, so a Vela CLI living outside those (a host path like
|
||||
// `~/.local/bin/vela` is the common dev case) would be invisible inside.
|
||||
// Bind-mount the containing directory read-only and rewrite the env to
|
||||
// the container-side path so `copyOptionalVelaCliBinary` can actually
|
||||
// read it.
|
||||
const hostVelaDir = dirname(velaBinHost);
|
||||
const velaBinBase = basename(velaBinHost);
|
||||
const containerVelaDir = "/opt/vela-cli";
|
||||
dockerArgs.push("-v", `${hostVelaDir}:${containerVelaDir}:ro`);
|
||||
dockerArgs.push("-e", `OPEN_DESIGN_VELA_CLI_BIN=${containerVelaDir}/${velaBinBase}`);
|
||||
}
|
||||
if (config.amrProfile != null) {
|
||||
dockerArgs.push("-e", `OPEN_DESIGN_AMR_PROFILE=${config.amrProfile}`);
|
||||
|
|
|
|||
|
|
@ -143,12 +143,19 @@ describe("buildDockerArgs", () => {
|
|||
expect(args).toContain("OPEN_DESIGN_AMR_PROFILE=test");
|
||||
});
|
||||
|
||||
it("passes the Vela binary override into containerized builds when configured on the host", () => {
|
||||
it("bind-mounts the host Vela binary directory and rewrites the env path into the container", () => {
|
||||
const previous = process.env.OPEN_DESIGN_VELA_CLI_BIN;
|
||||
process.env.OPEN_DESIGN_VELA_CLI_BIN = "/host/bin/vela";
|
||||
try {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain("OPEN_DESIGN_VELA_CLI_BIN=/host/bin/vela");
|
||||
// The container only mounts /project, /tools-pack, and cache/home by
|
||||
// default — a host-path env value like `/host/bin/vela` would resolve
|
||||
// to a non-existent path inside. The directory must be bind-mounted
|
||||
// and the env rewritten to the container-side path so the resource
|
||||
// copier can actually read the binary.
|
||||
expect(args).toContain("/host/bin:/opt/vela-cli:ro");
|
||||
expect(args).toContain("OPEN_DESIGN_VELA_CLI_BIN=/opt/vela-cli/vela");
|
||||
expect(args).not.toContain("OPEN_DESIGN_VELA_CLI_BIN=/host/bin/vela");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPEN_DESIGN_VELA_CLI_BIN;
|
||||
else process.env.OPEN_DESIGN_VELA_CLI_BIN = previous;
|
||||
|
|
|
|||
Loading…
Reference in a new issue