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:
lefarcen 2026-05-28 11:28:42 +08:00
parent d55d7882a9
commit 13fc4f4852
8 changed files with 128 additions and 7 deletions

View file

@ -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');

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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`

View file

@ -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}`);

View file

@ -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;