fix(amr): resolve run model catalog from cache instead of a blocking per-run vela model list (#4075)

* fix(amr): resolve run model catalog from the shared cache instead of a blocking per-run `vela model list`

Packaged AMR runs failed with "AMR model … is not available from Vela.
Refresh the AMR model list, choose a supported model, and retry this run"
even for a logged-in user who had a real model selected.

Root cause: the AMR run preflight ran a fresh, blocking
`vela model list` (the authoritative remote catalog) on every turn and
fail-closed the run whenever that single call timed out or errored.
That call needs network reachability to the AMR gateway AND `$HOME`
(the offline `vela model preset` / `--version` calls need neither),
has a 10s ceiling, and only retries a narrow set of network errors.
Under CorpLink/飞连 the round-trip routinely approached the timeout, so
runs died at preflight — while the model picker still showed models
(seeded from the offline preset), making AMR look selectable but unusable.

Fix (daemon):
- Preflight now resolves the catalog through the same AmrModelLoadingCache
  the UI's /api/amr/models endpoint uses: a cached authoritative
  `vela model list` when hot, otherwise the offline `vela model preset`
  seed while a remote refresh runs in the background. The blocking remote
  probe is off the per-run hot path; a transient failure degrades to
  preset instead of fail-closing. vela's own `session/set_model` stays
  the final gate for a genuinely unsupported model.
- The previously-silent `catch {}` around the catalog probe now logs, so
  the failure is no longer invisible in logs/diagnostics.
- Backfill `$HOME` in the AMR spawn env: `execAgentFile` replaces the
  child environment wholesale, and `vela model list` exits non-zero with
  "$HOME is not defined" when it is missing (preset/--version do not),
  which silently decapitated the catalog probe.
- Raise the cache's remote-refresh interval from 60s to 10min: the
  catalog changes on the order of days, cached entries are returned
  instantly, and the per-run path now reads this cache.

Diagnostics: the export bundle now also carries the coding-agent CLIs'
own logs (claude / codex / opencode / amr) plus recent per-run
events.jsonl, via shared builders in @open-design/diagnostics used by
BOTH the daemon HTTP export and the desktop IPC export (the latter,
which users actually trigger, previously shipped neither run logs nor
agent logs). Secret-bearing files (~/.amr/config.json, ~/.codex/auth.json)
are never swept; only *.log files, tail-bounded and redacted.

* fix(diagnostics): resolve AMR OpenCode log dir from the effective OPENCODE_TEST_HOME

The AMR CLI log sweep hardcoded `<dataDir>/amr/opencode-home/...`, which
misses the provider logs as soon as a user sets
`agentCliEnv.amr.OPENCODE_TEST_HOME` (a supported app-config override that
spawnEnvForAgent preserves) — the run writes logs elsewhere while the
bundle keeps looking in the default path.

- buildAgentCliLogSources now takes an explicit `amrOpenCodeHome`; it
  sweeps `<amrOpenCodeHome>/.local/share/opencode/log` and falls back to
  the `<dataDir>/amr/opencode-home` default when not provided.
- The daemon export resolves the effective home through the live
  `spawnEnvForAgent('amr', …)` (honoring the app-config override) so the
  two cannot drift.
- The desktop export reads the override from app-config directly (it may
  not import daemon internals) with leading-`~` expansion, falling back to
  the default otherwise.

* fix(diagnostics): honor CLAUDE_CONFIG_DIR / CODEX_HOME overrides in the agent-log sweep

Same class as the AMR OpenCode-home fix: `agentCliEnv.claude.CLAUDE_CONFIG_DIR`
and `agentCliEnv.codex.CODEX_HOME` are supported app-config overrides
(`app-config.ts`), but the collector hardcoded `~/.claude` and `~/.codex/log`,
so a user who relocates either CLI home would have those provider logs dropped
from the bundle.

- buildAgentCliLogSources now takes `claudeConfigDir` / `codexHome`,
  falling back to `~/.claude` and `~/.codex` when not provided.
- The daemon export resolves all three homes (amr/claude/codex) through the
  live spawnEnvForAgent so they cannot drift from the spawn path.
- The desktop export reads the overrides from app-config directly with `~`
  expansion.

* fix(diagnostics): resolve OpenCode XDG_DATA_HOME from the effective spawn env

Sandbox mode (sandbox-mode.ts) rewrites XDG_DATA_HOME to a sandbox root, and
OpenCode resolves its data/log dir from XDG_DATA_HOME. The export was passing
the host process's XDG_DATA_HOME, so a sandboxed runtime would miss the
OpenCode CLI logs. Resolve it from `spawnEnvForAgent('opencode', …)` (which
reapplies sandbox env) alongside the amr/claude/codex homes; falls back to the
host value when unavailable. Sandbox is opt-in (OD_SANDBOX_MODE) and off in
packaged/dev runtimes, but this keeps the resolution consistent and correct.
This commit is contained in:
lefarcen
2026-06-10 10:53:27 +00:00
committed by GitHub
parent 53c77ff244
commit ae67ad41b1
9 changed files with 645 additions and 79 deletions
+65 -37
View File
@@ -1,11 +1,12 @@
import { homedir, userInfo } from 'node:os';
import { readdir, stat } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { dirname } from 'node:path';
import type { RequestHandler } from 'express';
import {
buildAgentCliLogSources,
buildDiagnosticsZip,
buildRunEventLogSources,
DIAGNOSTICS_CONTENT_TYPE,
DIAGNOSTICS_FILENAME_PREFIX,
diagnosticsFileName,
@@ -24,6 +25,56 @@ import {
} from '@open-design/sidecar';
import { readCurrentAppVersionInfo } from './app-version.js';
import { agentCliEnvForAgent, readAppConfig } from './app-config.js';
import { spawnEnvForAgent } from './agents.js';
interface ResolvedAgentHomes {
amrOpenCodeHome: string | null;
claudeConfigDir: string | null;
codexHome: string | null;
openCodeXdgDataHome: string | null;
}
// Resolve each agent CLI's effective home (OPENCODE_TEST_HOME / CLAUDE_CONFIG_DIR
// / CODEX_HOME) exactly as a real run would, by running the live
// `spawnEnvForAgent` resolver over the user's app-config overrides. This makes
// the diagnostics sweep honor `agentCliEnv.<agent>.*` relocations instead of
// only looking under the hardcoded defaults, and cannot drift from the spawn
// path. Returns nulls on any failure; the collector then falls back to defaults.
async function resolveAgentHomes(dataDir: string | null | undefined): Promise<ResolvedAgentHomes> {
const empty: ResolvedAgentHomes = {
amrOpenCodeHome: null,
claudeConfigDir: null,
codexHome: null,
openCodeXdgDataHome: null,
};
if (!dataDir) return empty;
try {
const appConfig = await readAppConfig(dataDir);
const envFor = (agentId: string) =>
spawnEnvForAgent(
agentId,
{ ...process.env, OD_DATA_DIR: dataDir },
agentCliEnvForAgent(appConfig.agentCliEnv, agentId),
);
const clean = (value: string | undefined): string | null => {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed : null;
};
return {
amrOpenCodeHome: clean(envFor('amr').OPENCODE_TEST_HOME),
claudeConfigDir: clean(envFor('claude').CLAUDE_CONFIG_DIR),
codexHome: clean(envFor('codex').CODEX_HOME),
// OpenCode resolves its data/log dir from XDG_DATA_HOME; sandbox mode
// rewrites that (sandbox-mode.ts), so read the EFFECTIVE value from the
// opencode spawn env rather than the host's, or the sweep misses the
// logs in a sandboxed runtime.
openCodeXdgDataHome: clean(envFor('opencode').XDG_DATA_HOME),
};
} catch {
return empty;
}
}
export interface DiagnosticsHandlerOptions {
/** Sidecar runtime context, present when daemon is launched via tools-dev or packaged sidecar. */
@@ -32,11 +83,11 @@ export interface DiagnosticsHandlerOptions {
projectRoot: string;
/** Directory containing per-run event logs at <runsDir>/<runId>/events.jsonl. */
runsDir?: string | null;
/** Open Design data dir (OD_DATA_DIR), used to locate the AMR OpenCode home. */
dataDir?: string | null;
}
const TAIL_BYTES_PER_LOG = 4 * 1024 * 1024;
const TAIL_BYTES_PER_RUN_EVENT_LOG = 2 * 1024 * 1024;
const MAX_RUN_EVENT_LOGS = 20;
function safeUsername(): string | undefined {
try {
@@ -93,48 +144,25 @@ function buildSidecarLogSources(runtime: SidecarRuntimeContext<SidecarStamp> | n
return sources;
}
async function buildRunEventLogSources(runsDir: string | null | undefined): Promise<LogSource[]> {
if (!runsDir) return [];
let entries;
try {
entries = await readdir(runsDir, { withFileTypes: true });
} catch {
return [];
}
const candidates: Array<{ runId: string; absolutePath: string; mtimeMs: number }> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!/^[A-Za-z0-9._-]+$/u.test(entry.name)) continue;
const absolutePath = join(runsDir, entry.name, 'events.jsonl');
try {
const info = await stat(absolutePath);
if (!info.isFile()) continue;
candidates.push({ runId: entry.name, absolutePath, mtimeMs: info.mtimeMs });
} catch {
continue;
}
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates.slice(0, MAX_RUN_EVENT_LOGS).map(({ runId, absolutePath }) => ({
name: `runs/${runId}/events.jsonl`,
absolutePath,
kind: 'text',
tailBytes: TAIL_BYTES_PER_RUN_EVENT_LOG,
}));
}
export function createDiagnosticsExportHandler(options: DiagnosticsHandlerOptions): RequestHandler {
return async (_req, res) => {
try {
const versionInfo = await readCurrentAppVersionInfo().catch(() => null);
const home = homedir();
const agentHomes = await resolveAgentHomes(options.dataDir);
const sources = [
...buildSidecarLogSources(options.runtime),
...(await buildRunEventLogSources(options.runsDir)),
...(await buildAgentCliLogSources({
homeDir: home,
dataDir: options.dataDir ?? null,
amrOpenCodeHome: agentHomes.amrOpenCodeHome,
claudeConfigDir: agentHomes.claudeConfigDir,
codexHome: agentHomes.codexHome,
xdgDataHome: agentHomes.openCodeXdgDataHome ?? process.env.XDG_DATA_HOME ?? null,
})),
];
const username = safeUsername();
const home = homedir();
const result = await buildDiagnosticsZip({
context: {
+8 -1
View File
@@ -17,7 +17,14 @@ type CacheState = {
lastRemoteError: string | null;
};
const DEFAULT_REMOTE_REFRESH_INTERVAL_MS = 60_000;
// The AMR model catalog changes rarely (new models land on the order of days),
// and a cached remote list is returned immediately while a refresh runs in the
// background — `get()` never blocks on the network when a cached entry exists.
// The per-run preflight now also reads this cache, so a tight interval would
// spawn `vela model list` far more often than the catalog actually changes.
// Refresh at most once every 10 minutes per cache key; callers always get the
// last-known catalog instantly in between.
const DEFAULT_REMOTE_REFRESH_INTERVAL_MS = 10 * 60_000;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error ?? 'unknown error');
+14
View File
@@ -1,3 +1,4 @@
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -96,6 +97,19 @@ export function spawnEnvForAgent(
if (agentId === 'amr') {
Object.assign(env, amrVelaProfileEnv(env));
Object.assign(env, amrAnalyticsIdentityEnv(env));
// `execAgentFile` REPLACES the child environment (execFile with `env`
// set), so anything missing here is genuinely absent for vela. `vela model
// list` resolves its config home up front and exits non-zero with
// "$HOME is not defined" when HOME is unset — while `vela model preset`
// and `vela --version` do not need it. A packaged daemon spawned with a
// stripped env (or any caller that did not forward HOME) would therefore
// detect AMR and seed the picker from preset, yet fail every run's remote
// catalog probe. Backfill HOME from the OS so the authoritative catalog
// call is never silently decapitated by a missing home dir.
if (!env.HOME?.trim()) {
const home = os.homedir();
if (home) env.HOME = home;
}
// Identify Open Design as the host so the vela CLI tags its command +
// model_request analytics with source=open_design (revenue attribution).
// Not PII (unlike the installation id above), so set it regardless of the
+66 -36
View File
@@ -5535,6 +5535,7 @@ export async function startServer({
runtime,
projectRoot: PROJECT_ROOT,
runsDir: path.join(RUNTIME_DATA_DIR, 'runs'),
dataDir: RUNTIME_DATA_DIR,
}),
);
@@ -12247,16 +12248,39 @@ export async function startServer({
agentLaunch,
)
: null;
const amrModelScope = resolveAmrProfile(modelProbeEnv ?? process.env);
// Resolve the AMR model catalog through the SAME shared cache the UI's
// `/api/amr/models` endpoint serves (AmrModelLoadingCache): a cached
// authoritative `vela model list` when it is hot, otherwise the offline
// `vela model preset` seed while a remote refresh runs in the background.
//
// Why not a fresh `vela model list` per run: that authoritative call
// needs network reachability to the AMR gateway AND `$HOME` (the offline
// `preset`/`--version` calls need neither), takes up to ~10s, and only
// retries a narrow set of network errors. Running it blocking on every
// turn turned any transient gateway/timeout/HOME hiccup into a hard
// "AMR model … is not available from Vela" — even for a logged-in user
// who already picked a real model the picker surfaced from the preset
// seed. Under CorpLink/飞连 the call routinely exceeded the timeout, so
// AMR became unusable in packaged nightlies. Reusing the cache keeps that
// blocking probe off the per-run hot path and degrades to preset instead
// of fail-closing; vela's own `session/set_model` remains the final gate.
let liveModels = [];
try {
liveModels =
launchPath && typeof def.fetchModels === 'function'
? ((await def.fetchModels(launchPath, modelProbeEnv)) ?? [])
: [];
} catch {
const probe = await resolveAmrModelProbe();
const catalog = await amrModelLoadingCache.get(probe.cacheKey, {
fetchPreset: () => fetchVelaPresetModels(probe.launchPath, probe.env),
fetchRemote: () => fetchVelaRemoteModelsWithRetry(probe.launchPath, probe.env),
});
liveModels = catalog.models ?? [];
} catch (error) {
// Do not swallow silently: a probe failure here is exactly what made
// the packaged AMR breakage undiagnosable (the old `catch {}` left no
// trace in any log or diagnostics bundle). Record it and degrade to the
// remembered catalog below.
console.warn('[amr] model catalog preflight probe failed', error);
liveModels = [];
}
const amrModelScope = resolveAmrProfile(modelProbeEnv ?? process.env);
const rememberedLiveModels = getRememberedLiveModels(def.id, amrModelScope);
if (liveModels.length > 0) {
rememberLiveModels(def.id, liveModels, amrModelScope);
@@ -12265,12 +12289,26 @@ export async function startServer({
const liveModelIds = new Set(
liveModels.map((candidate) => candidate?.id).filter(Boolean),
);
// A request that came in as 'default'/empty is normally pre-resolved to a
// concrete id via the agent-wide cached model order; if it still is not,
// adopt the first catalog entry so the spawn layer always has a real id.
const userAskedForDefault =
typeof model !== 'string' ||
!model.trim() ||
model.trim().toLowerCase() === 'default';
if (
!safeModel ||
safeModel === 'default' ||
(userAskedForDefault && !liveModelIds.has(safeModel))
) {
safeModel = liveModels[0]?.id ?? safeModel ?? null;
agentOptions.model = safeModel;
}
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.
// The catalog is genuinely empty: even the offline preset seed could
// not be read, which almost always means the user is signed out (`vela`
// catalog calls 401) or the CLI is unrunnable. Prefer the relogin
// affordance over a misleading "choose a model".
if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(
modelProbeEnv ?? process.env,
@@ -12286,37 +12324,29 @@ export async function startServer({
return design.runs.finish(run, 'failed', 1, null);
}
}
send('error', createAmrModelUnavailablePayload(safeModel, {
reason: 'model_catalog_unavailable',
}));
return design.runs.finish(run, 'failed', 1, null);
}
// `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;
}
if (!safeModel || !liveModelIds.has(safeModel)) {
// Logged in but no catalog at all AND no resolvable model: only now is
// there nothing safe to forward, so surface the model error.
if (!safeModel) {
send('error', createAmrModelUnavailablePayload(safeModel, {
reason: 'model_catalog_unavailable',
}));
return design.runs.finish(run, 'failed', 1, null);
}
// Otherwise fall through with the user's selected model and let vela's
// `session/set_model` be the authoritative gate.
} else if (!safeModel) {
// Catalog known but we could not resolve any model id to forward.
send('error', createAmrModelUnavailablePayload(
typeof model === 'string' && model.trim() ? model : safeModel,
{ availableModels: [...liveModelIds] },
));
return design.runs.finish(run, 'failed', 1, null);
}
// NOTE: when the selected model is absent from the (possibly preset-only
// or stale) catalog we intentionally do NOT fail-close. The cached/preset
// catalog can lag the live one, and a logged-in user picked a concrete
// id; vela rejects a truly unsupported model at `session/set_model` with
// a precise error, which beats a pre-emptive block on a flaky metadata read.
}
// Plain-streaming adapters that own a "continue most recent
+92 -3
View File
@@ -329,12 +329,19 @@ process.exit(1);
);
});
it('retries transient AMR Link catalog failures before aborting startup', async () => {
it('survives transient AMR Link catalog failures without aborting the run', async () => {
// The run preflight resolves the AMR catalog through the shared
// AmrModelLoadingCache, which degrades to the offline `vela model preset`
// seed whenever the authoritative `vela model list` is momentarily
// unavailable (and refreshes the remote catalog in the background). So a
// transient catalog failure must NOT abort the run — the per-run path no
// longer blocks on a synchronous `model list` retry loop.
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
const previousLinkUrl = process.env.VELA_LINK_URL;
const stateFile = join(tmpdir(), `od-amr-model-retry-${randomUUID()}.json`);
try {
process.env.VELA_RUNTIME_KEY = 'fake-runtime-key';
// Unique key so the shared model cache key is unique per test run.
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
await withFakeAgent(
@@ -381,8 +388,13 @@ child.on('exit', (code, signal) => {
expect(body).toContain('"type":"text_delta","delta":"Hello from fake "');
expect(body).toContain('"type":"text_delta","delta":"vela."');
expect(body).not.toContain('model_catalog_unavailable');
expect(body).not.toContain('AMR_MODEL_UNAVAILABLE');
// The catalog probe runs at least once (remote attempted, then the
// run proceeds from the preset seed). We no longer assert an exact
// synchronous retry count: the remote retry/backoff now happens in
// the cache's background refresh, not on the per-run hot path.
const attempts = JSON.parse(readFileSync(stateFile, 'utf8')) as { attempts: number };
expect(attempts.attempts).toBe(3);
expect(attempts.attempts).toBeGreaterThanOrEqual(1);
},
);
} finally {
@@ -394,6 +406,83 @@ child.on('exit', (code, signal) => {
}
});
it('proceeds with the AMR run via the cached/preset catalog when the live model list is unavailable', async () => {
// Red spec for the packaged-nightly "AMR model the selected model is not
// available from Vela" report: the run preflight used to do a fresh,
// blocking `vela model list` (authoritative remote catalog) on EVERY run
// and fail-close the run whenever that single call timed out / errored —
// even though the user is logged in, the model picker already shows a
// model (seeded from the offline `vela model preset`), and the selected
// model is real. Under CorpLink/飞连 the remote call routinely exceeds the
// 10s timeout, so a logged-in user with a valid model could not run AMR at
// all. The fix reuses the shared AmrModelLoadingCache (cached remote when
// hot, otherwise the offline preset seed) instead of a per-run blocking
// remote probe, so a transient `model list` failure no longer kills the run.
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
const previousLinkUrl = process.env.VELA_LINK_URL;
try {
// A unique runtime key both marks the user as logged-in AND makes the
// shared model cache key unique so this case never reuses another test's
// cached remote catalog.
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
await withFakeAgent(
'vela',
`
const { spawn } = require('node:child_process');
const fixture = ${JSON.stringify(FAKE_VELA_FIXTURE)};
const args = process.argv.slice(2);
// Simulate a persistently unreachable authoritative catalog (gateway
// timeout / 飞连 congestion): every \`vela model list\` fails. \`model preset\`,
// \`login\`, and \`agent run\` still delegate to the fixture, mirroring the real
// CLI where the offline preset and the ACP run do not need the gateway.
if (args[0] === 'model' && args[1] === 'list') {
process.stderr.write('Get "https://amr-link.open-design.ai/v1/models": context deadline exceeded\\n');
process.exit(1);
}
const child = spawn(process.execPath, [fixture, ...args], {
stdio: 'inherit',
env: process.env,
});
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 0);
});
`,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'amr',
message: 'hello',
// Present in the preset seed (DEFAULT_MODEL_PRESET_JSON) but the
// live `model list` is unavailable, so only the preset path can
// surface it.
model: 'glm-5.1',
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
// The run must NOT be fail-closed on the unavailable live catalog.
expect(body).not.toContain('AMR_MODEL_UNAVAILABLE');
expect(body).not.toContain('model_catalog_unavailable');
expect(body).not.toContain('is not available from Vela');
// It must actually proceed into the ACP run and stream assistant text.
expect(body).toContain('"type":"text_delta","delta":"Hello from fake "');
expect(body).toContain('"type":"text_delta","delta":"vela."');
},
);
} finally {
if (previousRuntimeKey == null) delete process.env.VELA_RUNTIME_KEY;
else process.env.VELA_RUNTIME_KEY = previousRuntimeKey;
if (previousLinkUrl == null) delete process.env.VELA_LINK_URL;
else process.env.VELA_LINK_URL = previousLinkUrl;
}
});
it('allows plugin authoring to succeed when the requested generated-plugin artifacts exist before close', async () => {
const projectId = `proj-plugin-authoring-success-${randomUUID()}`;
+69 -2
View File
@@ -1,4 +1,4 @@
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os";
import { dirname, join } from "node:path";
@@ -17,7 +17,9 @@ import {
} from "@open-design/sidecar";
import {
DIAGNOSTICS_FILENAME_PREFIX,
buildAgentCliLogSources,
buildDiagnosticsZip,
buildRunEventLogSources,
diagnosticsFileName,
type LogSource,
} from "@open-design/diagnostics";
@@ -40,6 +42,43 @@ function safeUsername(): string | undefined {
}
}
function expandTilde(value: unknown): string | null {
if (typeof value !== "string" || value.trim().length === 0) return null;
const trimmed = value.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
return trimmed;
}
interface AgentHomeOverrides {
amrOpenCodeHome: string | null;
claudeConfigDir: string | null;
codexHome: string | null;
}
// Best-effort read of user `agentCliEnv.<agent>.*` home overrides
// (`OPENCODE_TEST_HOME` / `CLAUDE_CONFIG_DIR` / `CODEX_HOME`) from the daemon's
// app-config, so the log sweep targets the same homes a real run uses. The
// daemon resolves these authoritatively via spawnEnvForAgent; the desktop main
// process may not import daemon internals, so it reads the config file directly
// and applies only leading-`~` expansion (overrides are effectively absolute).
// Missing values fall back to the collector's defaults.
async function readAgentHomeOverrides(dataDir: string): Promise<AgentHomeOverrides> {
try {
const raw = await readFile(join(dataDir, "app-config.json"), "utf8");
const agentCliEnv = (JSON.parse(raw) as {
agentCliEnv?: Record<string, Record<string, unknown> | undefined>;
}).agentCliEnv;
return {
amrOpenCodeHome: expandTilde(agentCliEnv?.amr?.OPENCODE_TEST_HOME),
claudeConfigDir: expandTilde(agentCliEnv?.claude?.CLAUDE_CONFIG_DIR),
codexHome: expandTilde(agentCliEnv?.codex?.CODEX_HOME),
};
} catch {
return { amrOpenCodeHome: null, claudeConfigDir: null, codexHome: null };
}
}
function buildSidecarLogSources(runtime: SidecarRuntimeContext<SidecarStamp>): LogSource[] {
// In packaged builds `runtime.base` is `<namespaceRoot>/runtime`, so the log
// tree lives a level UP at `<namespaceRoot>/logs`; `resolveRuntimeNamespaceRoot`
@@ -108,6 +147,34 @@ export async function exportDiagnosticsToFile(
}
try {
// The packaged daemon writes its runtime data at `<namespaceRoot>/data`
// (OD_DATA_DIR), with per-run event logs under `data/runs` and the
// AMR-managed OpenCode home under `data/amr`. Derive both so this export
// — the one users actually trigger from the desktop UI — carries the same
// run/agent diagnostics the daemon HTTP export does, instead of only the
// three sidecar logs.
const namespaceRoot = resolveRuntimeNamespaceRoot({
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
runtime,
runtimeMode: SIDECAR_MODES.RUNTIME,
});
const dataDir = join(namespaceRoot, "data");
const runsDir = join(dataDir, "runs");
// Honor user `agentCliEnv.<agent>.*` home overrides so relocated CLI homes
// are not missed; absent values fall back to the collector's defaults.
const agentHomes = await readAgentHomeOverrides(dataDir);
const sources: LogSource[] = [
...buildSidecarLogSources(runtime),
...(await buildRunEventLogSources(runsDir)),
...(await buildAgentCliLogSources({
homeDir: homedir(),
dataDir,
amrOpenCodeHome: agentHomes.amrOpenCodeHome,
claudeConfigDir: agentHomes.claudeConfigDir,
codexHome: agentHomes.codexHome,
xdgDataHome: process.env.XDG_DATA_HOME ?? null,
})),
];
const result = await buildDiagnosticsZip({
context: {
app: { name: "open-design", version: app.getVersion(), packaged: app.isPackaged },
@@ -121,7 +188,7 @@ export async function exportDiagnosticsToFile(
sourceTag: runtime.source,
},
},
sources: buildSidecarLogSources(runtime),
sources,
redaction: { username: safeUsername() },
crashReports: {
// Restrict to Open Design's own process names. A generic "Electron"
+190
View File
@@ -0,0 +1,190 @@
import { readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import type { LogSource } from "./sources.js";
const DEFAULT_RUN_EVENT_TAIL_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_RUN_EVENT_LOGS = 20;
// CLI session logs can be large (e.g. Codex's `codex-tui.log` grows into the
// hundreds of MB because it logs every TUI frame), so keep a generous-but-
// bounded tail that still captures the final error frame.
const DEFAULT_AGENT_LOG_TAIL_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_FILES_PER_AGENT = 3;
const SAFE_DIR_ENTRY = /^[A-Za-z0-9._-]+$/u;
async function statSafe(path: string): Promise<{ mtimeMs: number; isFile: boolean } | null> {
try {
const info = await stat(path);
return { mtimeMs: info.mtimeMs, isFile: info.isFile() };
} catch {
return null;
}
}
/**
* Collect the most-recent per-run event logs (`<runsDir>/<runId>/events.jsonl`).
*
* These are the daemon's capture of each agent CLI's own stdout/stderr/stream
* for a run — the single richest, agent-agnostic diagnostic signal for "the
* run failed / produced nothing". Shared so both the daemon HTTP export and the
* desktop IPC export bundle the same data.
*/
export async function buildRunEventLogSources(
runsDir: string | null | undefined,
options: { maxRuns?: number; tailBytes?: number } = {},
): Promise<LogSource[]> {
if (!runsDir) return [];
const maxRuns = options.maxRuns ?? DEFAULT_MAX_RUN_EVENT_LOGS;
const tailBytes = options.tailBytes ?? DEFAULT_RUN_EVENT_TAIL_BYTES;
let entries;
try {
entries = await readdir(runsDir, { withFileTypes: true });
} catch {
return [];
}
const candidates: Array<{ runId: string; absolutePath: string; mtimeMs: number }> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!SAFE_DIR_ENTRY.test(entry.name)) continue;
const absolutePath = join(runsDir, entry.name, "events.jsonl");
const info = await statSafe(absolutePath);
if (!info || !info.isFile) continue;
candidates.push({ runId: entry.name, absolutePath, mtimeMs: info.mtimeMs });
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates.slice(0, maxRuns).map(({ runId, absolutePath }) => ({
name: `runs/${runId}/events.jsonl`,
absolutePath,
kind: "text",
tailBytes,
}));
}
async function latestLogFilesIn(
dir: string,
max: number,
): Promise<Array<{ name: string; absolutePath: string }>> {
let names: string[];
try {
names = await readdir(dir);
} catch {
return [];
}
const candidates: Array<{ name: string; absolutePath: string; mtimeMs: number }> = [];
for (const name of names) {
if (!name.endsWith(".log")) continue;
if (!SAFE_DIR_ENTRY.test(name)) continue;
const absolutePath = join(dir, name);
const info = await statSafe(absolutePath);
if (!info || !info.isFile) continue;
candidates.push({ name, absolutePath, mtimeMs: info.mtimeMs });
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates.slice(0, max).map(({ name, absolutePath }) => ({ name, absolutePath }));
}
export interface AgentCliLogOptions {
/** User home directory; CLI configs/logs live under here. */
homeDir: string;
/** Open Design data dir (OD_DATA_DIR); fallback location of the AMR OpenCode home. */
dataDir?: string | null;
/**
* Effective AMR OpenCode home (the resolved `OPENCODE_TEST_HOME`). Callers
* should pass the value a real AMR run would use so this honors a user
* `agentCliEnv.amr.OPENCODE_TEST_HOME` override; when omitted we fall back to
* the default `<dataDir>/amr/opencode-home`.
*/
amrOpenCodeHome?: string | null;
/**
* Effective Claude config home (`CLAUDE_CONFIG_DIR`). Honors a user
* `agentCliEnv.claude.CLAUDE_CONFIG_DIR` override; falls back to `~/.claude`.
*/
claudeConfigDir?: string | null;
/**
* Effective Codex home (`CODEX_HOME`). Honors a user
* `agentCliEnv.codex.CODEX_HOME` override; falls back to `~/.codex`.
*/
codexHome?: string | null;
/** XDG_DATA_HOME, if set, used for OpenCode's data dir resolution. */
xdgDataHome?: string | null;
/** Max log files to include per agent (most-recent first). */
maxFilesPerAgent?: number;
/** Max bytes to read from each log file's tail. */
tailBytes?: number;
}
/**
* Collect the native on-disk logs of the coding-agent CLIs Open Design drives:
* Claude Code, Codex, OpenCode, and AMR (vela's OpenCode runtime). These
* complement the per-run `events.jsonl` capture with the CLI's own internal
* diagnostics (provider errors, retries, auth state) that never reach the
* daemon's stream.
*
* Deliberately excluded: secret-bearing files. We only sweep known *log*
* directories (`*.log` files) — never `~/.amr/config.json`, `~/.codex/auth.json`,
* or session transcripts — and the collected text still passes through the
* bundle's redaction pass.
*/
export async function buildAgentCliLogSources(options: AgentCliLogOptions): Promise<LogSource[]> {
const home = options.homeDir?.trim();
if (!home) return [];
const maxFiles = options.maxFilesPerAgent ?? DEFAULT_MAX_FILES_PER_AGENT;
const tailBytes = options.tailBytes ?? DEFAULT_AGENT_LOG_TAIL_BYTES;
const xdg = options.xdgDataHome?.trim();
const openCodeUserLogDir = xdg
? join(xdg, "opencode", "log")
: join(home, ".local", "share", "opencode", "log");
// Effective CLI homes honor the user's app-config overrides
// (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`), falling back to the defaults — the
// same fix as AMR's `OPENCODE_TEST_HOME` so relocated homes are not missed.
const claudeDir = options.claudeConfigDir?.trim() || join(home, ".claude");
const codexHome = options.codexHome?.trim() || join(home, ".codex");
// Per-agent log directories. `*.log` only; secret files (auth.json,
// config.json) live outside these dirs and are never swept.
const agentDirs: Array<{ agent: string; dir: string }> = [
// Claude Code operational logs (bash-commands / cost-tracker / daemon).
{ agent: "claude", dir: claudeDir },
// Codex tracing logs (codex-tui.log can be very large — tail-bounded).
{ agent: "codex", dir: join(codexHome, "log") },
// OpenCode session logs (provider errors live here in headless mode).
{ agent: "opencode", dir: openCodeUserLogDir },
];
// AMR drives a private OpenCode under OPENCODE_TEST_HOME; its session logs
// land under that home's XDG data dir, where AMR run provider failures are
// recorded. Prefer the effective home a real run resolves (honoring a user
// `agentCliEnv.amr.OPENCODE_TEST_HOME` override) and fall back to the default
// `<dataDir>/amr/opencode-home` so the bundle does not silently drop the very
// AMR logs this is meant to surface.
const amrHome = options.amrOpenCodeHome?.trim();
const dataDir = options.dataDir?.trim();
const amrOpenCodeHome = amrHome || (dataDir ? join(dataDir, "amr", "opencode-home") : "");
if (amrOpenCodeHome) {
agentDirs.push({
agent: "amr",
dir: join(amrOpenCodeHome, ".local", "share", "opencode", "log"),
});
}
const sources: LogSource[] = [];
for (const { agent, dir } of agentDirs) {
const files = await latestLogFilesIn(dir, maxFiles);
for (const file of files) {
sources.push({
name: `agent-cli-logs/${agent}/${file.name}`,
absolutePath: file.absolutePath,
kind: "text",
tailBytes,
});
}
}
return sources;
}
+6
View File
@@ -36,3 +36,9 @@ export {
type DiagnosticsExportInput,
type DiagnosticsExportResult,
} from "./zip.js";
export {
buildRunEventLogSources,
buildAgentCliLogSources,
type AgentCliLogOptions,
} from "./agent-logs.js";
@@ -0,0 +1,135 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildAgentCliLogSources, buildRunEventLogSources } from "../src/agent-logs.js";
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "diagnostics-agent-logs-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
async function touch(path: string, content = "x"): Promise<void> {
await mkdir(join(path, ".."), { recursive: true });
await writeFile(path, content, "utf8");
}
describe("buildRunEventLogSources", () => {
it("returns [] when no runs dir", async () => {
expect(await buildRunEventLogSources(null)).toEqual([]);
expect(await buildRunEventLogSources(join(tempDir, "missing"))).toEqual([]);
});
it("collects the most-recent per-run events.jsonl, newest first", async () => {
const runsDir = join(tempDir, "runs");
await touch(join(runsDir, "run-a", "events.jsonl"), "a");
await touch(join(runsDir, "run-b", "events.jsonl"), "b");
// Directory without events.jsonl is skipped.
await mkdir(join(runsDir, "run-empty"), { recursive: true });
const sources = await buildRunEventLogSources(runsDir, { maxRuns: 5 });
const names = sources.map((s) => s.name).sort();
expect(names).toEqual(["runs/run-a/events.jsonl", "runs/run-b/events.jsonl"]);
for (const source of sources) {
expect(source.kind).toBe("text");
expect(source.tailBytes).toBeGreaterThan(0);
}
});
it("caps the number of runs", async () => {
const runsDir = join(tempDir, "runs");
for (let i = 0; i < 5; i += 1) {
await touch(join(runsDir, `run-${i}`, "events.jsonl"), String(i));
}
const sources = await buildRunEventLogSources(runsDir, { maxRuns: 2 });
expect(sources).toHaveLength(2);
});
});
describe("buildAgentCliLogSources", () => {
it("collects claude / codex / opencode / amr log files under their known dirs", async () => {
const home = join(tempDir, "home");
const dataDir = join(tempDir, "data");
await touch(join(home, ".claude", "daemon.log"));
await touch(join(home, ".codex", "log", "codex-tui.log"));
await touch(join(home, ".local", "share", "opencode", "log", "2026-01-01T00.log"));
await touch(
join(dataDir, "amr", "opencode-home", ".local", "share", "opencode", "log", "2026-01-02T00.log"),
);
// Secret-bearing files outside *.log dirs must NOT be swept.
await touch(join(home, ".codex", "auth.json"), "secret");
await touch(join(home, ".amr", "config.json"), "secret");
const sources = await buildAgentCliLogSources({ homeDir: home, dataDir });
const names = sources.map((s) => s.name);
expect(names).toContain("agent-cli-logs/claude/daemon.log");
expect(names).toContain("agent-cli-logs/codex/codex-tui.log");
expect(names).toContain("agent-cli-logs/opencode/2026-01-01T00.log");
expect(names).toContain("agent-cli-logs/amr/2026-01-02T00.log");
// No secrets, and only *.log files.
expect(names.some((n) => n.includes("auth.json"))).toBe(false);
expect(names.some((n) => n.includes("config.json"))).toBe(false);
for (const source of sources) {
expect(source.name.endsWith(".log")).toBe(true);
expect(source.tailBytes).toBeGreaterThan(0);
}
});
it("honors an explicit amrOpenCodeHome override over the dataDir default", async () => {
const home = join(tempDir, "home");
const dataDir = join(tempDir, "data");
const overrideHome = join(tempDir, "custom-amr-home");
// Default location has a log, but the override points elsewhere — only the
// override's logs should be swept (mirrors a user OPENCODE_TEST_HOME).
await touch(
join(dataDir, "amr", "opencode-home", ".local", "share", "opencode", "log", "default.log"),
);
await touch(
join(overrideHome, ".local", "share", "opencode", "log", "override.log"),
);
const sources = await buildAgentCliLogSources({ homeDir: home, dataDir, amrOpenCodeHome: overrideHome });
const amrNames = sources.filter((s) => s.name.startsWith("agent-cli-logs/amr/")).map((s) => s.name);
expect(amrNames).toContain("agent-cli-logs/amr/override.log");
expect(amrNames).not.toContain("agent-cli-logs/amr/default.log");
});
it("honors claudeConfigDir / codexHome overrides over the home defaults", async () => {
const home = join(tempDir, "home");
const claudeDir = join(tempDir, "custom-claude");
const codexHome = join(tempDir, "custom-codex");
// Default homes have logs that must be ignored when overrides are set.
await touch(join(home, ".claude", "daemon.log"));
await touch(join(home, ".codex", "log", "codex-tui.log"));
await touch(join(claudeDir, "cost-tracker.log"));
await touch(join(codexHome, "log", "session.log"));
const sources = await buildAgentCliLogSources({ homeDir: home, claudeConfigDir: claudeDir, codexHome });
const names = sources.map((s) => s.name);
expect(names).toContain("agent-cli-logs/claude/cost-tracker.log");
expect(names).toContain("agent-cli-logs/codex/session.log");
expect(names).not.toContain("agent-cli-logs/claude/daemon.log");
expect(names).not.toContain("agent-cli-logs/codex/codex-tui.log");
});
it("honors XDG_DATA_HOME for the opencode user log dir", async () => {
const home = join(tempDir, "home");
const xdg = join(tempDir, "xdg");
await touch(join(xdg, "opencode", "log", "session.log"));
const sources = await buildAgentCliLogSources({ homeDir: home, xdgDataHome: xdg });
expect(sources.map((s) => s.name)).toContain("agent-cli-logs/opencode/session.log");
});
it("returns [] when homeDir is empty", async () => {
expect(await buildAgentCliLogSources({ homeDir: "" })).toEqual([]);
});
});