Compare commits

..

8 commits

Author SHA1 Message Date
YOMXXX
a97927d216
Merge b6f0c562b3 into 333a62cda6 2026-05-31 04:56:00 +00:00
李冠辰
b6f0c562b3 fix(daemon): invalidate Gemini resolver cache on binary replacement 2026-05-31 12:55:47 +08:00
kami
333a62cda6
fix: link od bin after fresh install (#2069)
* fix: link od bin after fresh install

* test: lock root od bin shim path

* test: cover root workspace deps in postinstall scan

* chore(nix): refresh pnpm deps hash
2026-05-31 04:36:49 +00:00
kami
def2e9fd2e
fix(web): dock comment side panel outside preview (#2073)
* fix: dock comment board without clipping inspect

When the comment-side dock falls back to the stacked layout in narrow
panes, collapsing the side panel now shrinks the bottom strip to a
horizontal rail height instead of keeping the full panel-height row.
commentPreviewCanvasSize() also stops over-deducting the expanded
panel height in the stacked-collapsed path so the canvas sizing stays
in sync with what is rendered.

* fix(web): address docked comment panel review follow-ups

* Fix non-docked comment tool tablet scaling

* test(web): align comment panel tests with collapse API
2026-05-31 04:36:15 +00:00
Denis Redozubov
729ce2b0cb
feat(daemon): add run-scoped MCP tool bundles (#3244)
* feat(daemon): add run-scoped MCP tool bundles

* fix(daemon): keep sandbox runs in managed project dirs

* fix(daemon): reject malformed run tool bundles

* fix(contracts): model run-scoped mcp server inputs

* fix(daemon): reject unsupported run tool bundles

* fix(daemon): validate run tools before chat fallback

* test(daemon): expect sandbox imported folder failure

* fix(daemon): preflight sandbox project roots before run rows

* fix(daemon): preflight sandbox chat project roots

* fix(daemon): allow host editor for sandbox imports

* fix(daemon): preflight sandbox routine project reuse

* fix(daemon): reject undeliverable Claude tool bundles

* fix(daemon): single-source chat route validation
2026-05-31 03:53:04 +00:00
蓝宙
e8c179d3a6
fix: show cumulative conversation duration (#3354)
* fix: show cumulative conversation duration

* fix: include usage-only run durations

---------

Co-authored-by: Lanzhou3 <217479610+Lanzhou3@users.noreply.github.com>
2026-05-31 03:52:12 +00:00
estelledc
0b493a66c0
fix(web): prevent caret reset on tools-menu picker mousedown (#3368)
The right-side @-button tools popover (ToolsPluginsPanel,
ToolsSkillsPanel, ToolsMcpPanel) inserts text into the composer
draft using the textarea's selectionStart at click time, but the
picker rows had `onClick` without `onMouseDown={(e) =>
e.preventDefault()}`. On a real mouse, mousedown fires first, the
textarea loses focus before the click handler runs, and
selectionStart resets — so the inserted token lands at offset 0
instead of at the user's cursor.

The @-mention popover already prevents this by calling
preventDefault on mousedown for every picker row (the comment at
ChatComposer.tsx:3039-3043 explains the reason). This change
mirrors that protection on the three tools-menu pickers.

The mention popover itself was unaffected, so design-file
mentions (which only flow through the @-popover via
`replaceMentionWithText`) are not impacted by this issue. The
reporter's mention of "design files" appears to refer to picking a
file via the @-popover, where the protection was already in place.

Closes #3195

Validation:
- pnpm exec vitest run tests/components/ChatComposer.tools-menu-caret.test.tsx
  → 3/3 passed (red on main, asserts each picker calls
  preventDefault on mousedown)
- pnpm --filter @open-design/web test → 2501/2501 passed (260 files)
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green
2026-05-31 03:50:45 +00:00
mehmet turac
8448b1105c
fix: preserve OpenClaude fallback credentials (#3361)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped
2026-05-31 03:49:25 +00:00
37 changed files with 2817 additions and 492 deletions

16
apps/daemon/bin/od.mjs Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const entryDir = dirname(fileURLToPath(import.meta.url));
const distEntry = resolve(entryDir, "../dist/cli.js");
if (!existsSync(distEntry)) {
throw new Error(
`Open Design daemon dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/daemon build" first.`,
);
}
await import(pathToFileURL(distEntry).href);

View file

@ -6,7 +6,7 @@
"main": "./dist/cli.js",
"types": "./dist/cli.d.ts",
"bin": {
"od": "./dist/cli.js"
"od": "./bin/od.mjs"
},
"exports": {
".": {
@ -20,6 +20,7 @@
}
},
"files": [
"bin",
"dist",
"package.json"
],

View file

@ -19,7 +19,6 @@ import { isSafeId as isSafeProjectId } from './projects.js';
import { projectKindToTracking } from '@open-design/contracts/analytics';
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
import { googleStreamGenerateContentUrl } from './google-models.js';
import { parseMediaExecutionPolicyInput } from './media-policy.js';
import { createRoleMarkerGuard } from './role-marker-guard.js';
// Allowlist for the `/feedback` route. Mirrors the
@ -45,7 +44,7 @@ export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'htt
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { startChatRun, submitToolResultToRun } = ctx.chat;
const { submitToolResultToRun } = ctx.chat;
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
const {
handleCritiqueArtifact,
@ -54,7 +53,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
critiqueResponseCapBytes,
critiqueRunRegistry,
} = ctx.critique;
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
if (
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
@ -79,6 +77,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
// so any handler we wired here was shadowed and never executed. Plugin
// snapshot resolution, clientType inference, and the daemon-side
// run_created/finished analytics all live in `server.ts` now.
// POST /api/chat is likewise owned by `server.ts`; keep the chat run
// launch path single-sourced so validation changes land on the live route.
app.get('/api/runs', (req, res) => {
const { projectId, conversationId, status } = req.query;
@ -218,23 +218,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
res.status(202).json(outcome);
});
app.post('/api/chat', (req, res) => {
if (isDaemonShuttingDown()) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const body = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(
(body as { mediaExecution?: unknown }).mediaExecution,
);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const runBody = { ...body, mediaExecution: mediaExecution.policy };
const run = design.runs.create(runBody);
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(runBody, run));
});
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
// Settings dialog uses these to verify a config works without sending a
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused

View file

@ -1,3 +1,5 @@
import path from 'node:path';
import { redactSecrets } from './redact.js';
export interface ClaudeCliDiagnosticInput {
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
stderrTail?: string | null;
stdoutTail?: string | null;
env?: Record<string, unknown> | null;
resolvedBin?: string | null;
}
export interface ClaudeCliDiagnostic {
@ -51,6 +54,15 @@ function withContext(
};
}
function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' {
if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude';
const base = path
.basename(input.resolvedBin.trim().replace(/\\/g, '/'))
.replace(/\.(exe|cmd|bat)$/i, '')
.toLowerCase();
return base === 'openclaude' ? 'openclaude' : 'claude';
}
export function diagnoseClaudeCliFailure(
input: ClaudeCliDiagnosticInput,
): ClaudeCliDiagnostic | null {
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
const normalized = text.toLowerCase();
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
const runtime = selectedClaudeCompatibleRuntime(input);
const isOpenClaude = runtime === 'openclaude';
const customEndpointConnectionFailure =
hasCustomBaseUrl &&
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
);
}
if (authFailure) {
if (isOpenClaude) {
return withContext(
'OpenClaude could not authenticate with its configured endpoint.',
'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.',
input,
);
}
const configHint = hasConfigDir
? 'The configured Claude config directory may contain stale or expired auth state.'
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure(
}
if (!text.trim() && input.exitCode === 1) {
if (isOpenClaude) {
return withContext(
'OpenClaude exited before producing diagnostics.',
'Check the OpenClaude API key, endpoint, and local configuration, then retry.',
input,
);
}
const message = hasConfigDir
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
: 'Claude Code exited before producing diagnostics.';

View file

@ -1869,6 +1869,8 @@ async function testAgentConnectionInternal(
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: executableResolution.selectedPath },
);
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
@ -2033,6 +2035,7 @@ async function testAgentConnectionInternal(
stderrTail,
stdoutTail: rawStdoutTail || buffered,
env,
resolvedBin: executableResolution.selectedPath,
});
if (claudeDiagnostic) {
console.warn(

View file

@ -752,12 +752,23 @@ export function listConversations(db: SqliteDb, projectId: string) {
AND m.run_status IS NOT NULL
)
WHERE rn = 1
),
total_run_durations AS (
SELECT m.conversation_id AS conversationId,
SUM(${terminalRunDurationSql('m')}) AS totalDurationMs
FROM messages m
JOIN project_conversations c ON c.id = m.conversation_id
WHERE m.role = 'assistant'
AND m.run_status IN ('succeeded', 'failed', 'canceled')
GROUP BY m.conversation_id
)
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
lr.latestRunStatus, lr.latestRunStartedAt,
lr.latestRunEndedAt, lr.latestRunEventsJson
lr.latestRunEndedAt, lr.latestRunEventsJson,
trd.totalDurationMs
FROM project_conversations c
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
LEFT JOIN total_run_durations trd ON trd.conversationId = c.id
ORDER BY c.updatedAt DESC`,
)
.all(projectId)).map(normalizeConversation);
@ -775,6 +786,7 @@ export function getConversation(db: SqliteDb, id: string) {
return {
...normalizeConversation(r),
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
...numberProperty('totalDurationMs', totalConversationRunDurationMs(db, r.id)),
};
}
@ -791,10 +803,16 @@ function normalizeConversation(r: DbRow) {
title: r.title ?? null,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
...numberProperty('totalDurationMs', r.totalDurationMs),
latestRun: latestRun ?? undefined,
};
}
function numberProperty(key: string, value: unknown) {
const n = value == null ? undefined : Number(value);
return typeof n === 'number' && Number.isFinite(n) ? { [key]: n } : {};
}
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
const row = db
.prepare(
@ -813,6 +831,50 @@ function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
return conversationRunSummaryFromRow(row);
}
function totalConversationRunDurationMs(db: SqliteDb, conversationId: string): number | undefined {
const row = db
.prepare(
`SELECT SUM(${terminalRunDurationSql()}) AS totalDurationMs
FROM messages
WHERE conversation_id = ?
AND role = 'assistant'
AND run_status IN ('succeeded', 'failed', 'canceled')`,
)
.get(conversationId) as DbRow | undefined;
return row?.totalDurationMs == null ? undefined : Number(row.totalDurationMs);
}
function terminalRunDurationSql(alias?: string) {
const p = alias ? `${alias}.` : '';
return `CASE
WHEN ${p}started_at IS NOT NULL AND ${p}ended_at IS NOT NULL THEN
CASE
WHEN CAST(${p}ended_at AS INTEGER) >= CAST(${p}started_at AS INTEGER)
THEN CAST(${p}ended_at AS INTEGER) - CAST(${p}started_at AS INTEGER)
ELSE 0
END
ELSE (
SELECT CASE
WHEN json_extract(usage_event.value, '$.durationMs') >= 0
THEN json_extract(usage_event.value, '$.durationMs')
ELSE 0
END
FROM json_each(
CASE
WHEN json_valid(${p}events_json) AND json_type(${p}events_json) = 'array'
THEN ${p}events_json
ELSE '[]'
END
) AS usage_event
WHERE usage_event.type = 'object'
AND json_extract(usage_event.value, '$.kind') = 'usage'
AND json_type(usage_event.value, '$.durationMs') IN ('integer', 'real')
ORDER BY CAST(usage_event.key AS INTEGER) DESC
LIMIT 1
)
END`;
}
function conversationRunSummaryFromRow(row: DbRow | undefined) {
if (!row || typeof row.runStatus !== 'string') return null;
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);

View file

@ -15,6 +15,7 @@
import { spawn } from 'node:child_process';
import { access, constants as fsConstants } from 'node:fs/promises';
import path from 'node:path';
import type { Express } from 'express';
import type {
HostEditor,
@ -159,6 +160,28 @@ function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boole
return true;
}
function projectHostOpenDir(
projectsRoot: string,
project: { id: string; metadata?: { baseDir?: unknown } | null },
resolveProjectDir: (
projectsRoot: string,
projectId: string,
metadata?: unknown,
opts?: { allowUnavailableSandboxImportedProject?: boolean },
) => string,
): string {
const importedBaseDir =
typeof project.metadata?.baseDir === 'string'
? path.normalize(project.metadata.baseDir)
: '';
if (importedBaseDir && path.isAbsolute(importedBaseDir)) {
return importedBaseDir;
}
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
allowUnavailableSandboxImportedProject: true,
});
}
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
const { db } = ctx;
const { sendApiError } = ctx.http;
@ -209,7 +232,11 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const resolvedDir = projectHostOpenDir(
PROJECTS_DIR,
project,
resolveProjectDir,
);
const probe = await resolveEntry(entry);
if (!probe.available || !probe.launch) {
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);

View file

@ -872,7 +872,13 @@ async function callLocalCli(provider, system, user, options) {
}
const env = applyAgentLaunchEnv(
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
spawnEnvForAgent(
def.id,
{ ...process.env, ...(def.env || {}) },
configuredAgentEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);
const invocation = createCommandInvocation({

View file

@ -0,0 +1,185 @@
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import { sanitizeMcpConfig, sanitizeMcpServer } from './mcp-config.js';
export interface RunToolBundle {
mcpServers: McpServerConfig[];
}
export interface RunToolBundleSummary {
mcpServers: Array<{
id: string;
label?: string;
templateId?: string;
transport: McpTransport;
enabled: boolean;
authMode?: McpAuthMode;
}>;
}
export interface ExternalMcpSelection {
enabledServers: McpServerConfig[];
persistedTokenServerIds: Set<string>;
}
export type RunToolBundleParseResult =
| { ok: true; bundle: RunToolBundle }
| { ok: false; message: string };
export type RunToolBundleValidationResult =
| { ok: true }
| { ok: false; message: string };
export type RunToolBundleDeliveryTarget =
| 'managed-project'
| 'external-project'
| 'none';
export interface RunToolBundleValidationOptions {
deliveryTarget?: RunToolBundleDeliveryTarget;
}
type RunToolBundleAgent = Pick<
RuntimeAgentDef,
'id' | 'name' | 'externalMcpInjection'
>;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function agentLabel(agent: RunToolBundleAgent): string {
return agent.name ? `${agent.name} (${agent.id})` : agent.id;
}
export function normalizeRunToolBundleForRun(raw: unknown): RunToolBundle {
if (!isPlainObject(raw)) return { mcpServers: [] };
return {
mcpServers: sanitizeMcpConfig({ servers: raw.mcpServers }).servers,
};
}
export function parseRunToolBundleForRequest(raw: unknown): RunToolBundleParseResult {
if (raw == null) return { ok: true, bundle: { mcpServers: [] } };
if (!isPlainObject(raw)) {
return { ok: false, message: 'toolBundle must be an object' };
}
if (raw.mcpServers == null) return { ok: true, bundle: { mcpServers: [] } };
if (!Array.isArray(raw.mcpServers)) {
return { ok: false, message: 'toolBundle.mcpServers must be an array' };
}
const seen = new Set<string>();
const servers: McpServerConfig[] = [];
for (const [index, entry] of raw.mcpServers.entries()) {
const server = sanitizeMcpServer(entry);
if (!server) {
return {
ok: false,
message: `toolBundle.mcpServers[${index}] is invalid`,
};
}
if (seen.has(server.id)) {
return {
ok: false,
message: `toolBundle.mcpServers[${index}] duplicates server id "${server.id}"`,
};
}
seen.add(server.id);
servers.push(server);
}
return { ok: true, bundle: { mcpServers: servers } };
}
export function summarizeRunToolBundle(bundle: RunToolBundle | null | undefined): RunToolBundleSummary {
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
return {
mcpServers: servers.map((server) => ({
id: server.id,
...(server.label ? { label: server.label } : {}),
...(server.templateId ? { templateId: server.templateId } : {}),
transport: server.transport,
enabled: server.enabled,
...(server.authMode ? { authMode: server.authMode } : {}),
})),
};
}
export function validateRunToolBundleForAgent(
bundle: RunToolBundle | null | undefined,
agent: RunToolBundleAgent | null | undefined,
options: RunToolBundleValidationOptions = {},
): RunToolBundleValidationResult {
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
const enabledServers = servers.filter((server) => server.enabled);
if (enabledServers.length === 0) return { ok: true };
if (!agent) {
return {
ok: false,
message: 'toolBundle requires a supported agentId',
};
}
if (agent.externalMcpInjection === 'claude-mcp-json') {
if (options.deliveryTarget && options.deliveryTarget !== 'managed-project') {
return {
ok: false,
message:
`${agentLabel(agent)} receives run-scoped MCP tool bundles through project .mcp.json, ` +
'so toolBundle requires a daemon-managed project',
};
}
return { ok: true };
}
if (agent.externalMcpInjection === 'opencode-env-content') {
return { ok: true };
}
if (agent.externalMcpInjection === 'acp-merge') {
const unsupported = servers.findIndex(
(server) => server.enabled && server.transport !== 'stdio',
);
if (unsupported === -1) return { ok: true };
return {
ok: false,
message:
`toolBundle.mcpServers[${unsupported}] uses ${servers[unsupported]?.transport} transport, ` +
`but ${agentLabel(agent)} only supports stdio run-scoped MCP servers`,
};
}
return {
ok: false,
message: `${agentLabel(agent)} does not support run-scoped MCP tool bundles`,
};
}
export function resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode,
}: {
persistedServers: McpServerConfig[];
runScopedServers: McpServerConfig[];
sandboxMode: boolean;
}): ExternalMcpSelection {
const runScopedIds = new Set(runScopedServers.map((server) => server.id));
const persistedForRun = sandboxMode ? [] : persistedServers;
const byId = new Map<string, McpServerConfig>();
for (const server of persistedForRun) byId.set(server.id, server);
for (const server of runScopedServers) byId.set(server.id, server);
const persistedTokenServerIds = new Set<string>();
for (const server of persistedForRun) {
if (!server.enabled) continue;
if (runScopedIds.has(server.id)) continue;
persistedTokenServerIds.add(server.id);
}
return {
enabledServers: Array.from(byId.values()).filter((server) => server.enabled),
persistedTokenServerIds,
};
}

View file

@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
import {
normalizeRunToolBundleForRun,
summarizeRunToolBundle,
} from './run-tool-bundle.js';
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
@ -57,6 +61,7 @@ export function createChatRunService({
pluginId:
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
status: 'queued',
createdAt: now,
updatedAt: now,
@ -149,6 +154,7 @@ export function createChatRunService({
errorCode: run.errorCode ?? null,
eventsLogPath: run.eventsLogPath ?? null,
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
toolBundle: summarizeRunToolBundle(run.toolBundle),
});
const finish = (run, status, code: number | null = null, signal: string | null = null) => {

View file

@ -166,6 +166,8 @@ async function probe(
...(def.env || {}),
},
configuredEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);

View file

@ -15,6 +15,9 @@ import {
} from '../sandbox-mode.js';
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
type SpawnEnvOptions = {
resolvedBin?: string | null;
};
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
path.dirname(fileURLToPath(import.meta.url)),
@ -51,6 +54,7 @@ export function spawnEnvForAgent(
baseEnv: RuntimeEnvMap,
configuredEnv: unknown = {},
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
options: SpawnEnvOptions = {},
): NodeJS.ProcessEnv {
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
const env = mergeProxyAwareEnv(
@ -75,7 +79,9 @@ export function spawnEnvForAgent(
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'claude') {
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
if (!isOpenClaudeExecutable(options.resolvedBin)) {
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
}
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'codex') {
@ -88,6 +94,15 @@ export function spawnEnvForAgent(
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean {
if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false;
const base = path
.basename(resolvedBin.trim().replace(/\\/g, '/'))
.replace(/\.(exe|cmd|bat)$/i, '')
.toLowerCase();
return base === 'openclaude';
}
function sandboxRuntimeConfigForBaseEnv(
baseEnv: RuntimeEnvMap,
): SandboxRuntimeConfig | null {

View file

@ -1,4 +1,4 @@
import { accessSync, constants, existsSync, statSync } from 'node:fs';
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
import { delimiter } from 'node:path';
import path from 'node:path';
import { homedir } from 'node:os';
@ -286,10 +286,7 @@ export function inspectAgentExecutableResolution(
// falling back to first-match (#978).
let pathResolvedPath: string | null = null;
if (!configuredOverridePath && def.minVersion) {
const cached = versionAwareCache.get(def.id);
if (cached && existsSync(cached)) {
pathResolvedPath = cached;
}
pathResolvedPath = cachedVersionAwarePath(def.id);
}
if (!pathResolvedPath) {
const candidates = [
@ -324,13 +321,28 @@ export function inspectAgentExecutableResolution(
const VERSION_PROBE_TIMEOUT_MS = 1_500;
// agent.id → resolved path that passed the version gate. Populated by
// `chooseExecutableByMinVersion`; consulted by
interface VersionAwareExecutableIdentity {
realpath: string;
dev: number;
ino: number;
mode: number;
size: number;
mtimeMs: number;
ctimeMs: number;
}
interface VersionAwareCacheEntry {
path: string;
identity: VersionAwareExecutableIdentity;
}
// agent.id → resolved path + file identity that passed the version gate.
// Populated by `chooseExecutableByMinVersion`; consulted by
// `inspectAgentExecutableResolution` so the sync chat-spawn path sees
// the same pick detection landed on. Only writes for the auto-pick path
// — an explicit `<AGENT>_BIN` override is intentionally NOT cached so
// clearing the env reliably falls back to auto-pick (#1007 round-2 P2).
const versionAwareCache = new Map<string, string>();
const versionAwareCache = new Map<string, VersionAwareCacheEntry>();
export function clearVersionAwareResolutionCache(agentId?: string): void {
if (agentId === undefined) {
@ -340,6 +352,63 @@ export function clearVersionAwareResolutionCache(agentId?: string): void {
}
}
function readVersionAwareExecutableIdentity(filePath: string): VersionAwareExecutableIdentity | null {
try {
const realpath = realpathSync(filePath);
const stat = statSync(realpath);
if (!stat.isFile()) return null;
return {
realpath,
dev: stat.dev,
ino: stat.ino,
mode: stat.mode,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
};
} catch {
return null;
}
}
function versionAwareExecutableIdentityEquals(
a: VersionAwareExecutableIdentity,
b: VersionAwareExecutableIdentity,
): boolean {
return (
a.realpath === b.realpath &&
a.dev === b.dev &&
a.ino === b.ino &&
a.mode === b.mode &&
a.size === b.size &&
a.mtimeMs === b.mtimeMs &&
a.ctimeMs === b.ctimeMs
);
}
function cachedVersionAwarePath(agentId: string): string | null {
const cached = versionAwareCache.get(agentId);
if (!cached) return null;
const currentIdentity = readVersionAwareExecutableIdentity(cached.path);
if (
!currentIdentity ||
!versionAwareExecutableIdentityEquals(cached.identity, currentIdentity)
) {
versionAwareCache.delete(agentId);
return null;
}
return cached.path;
}
function rememberVersionAwarePath(agentId: string, selectedPath: string): void {
const identity = readVersionAwareExecutableIdentity(selectedPath);
if (!identity) {
versionAwareCache.delete(agentId);
return;
}
versionAwareCache.set(agentId, { path: selectedPath, identity });
}
// Strict, anchored semver parse. Accepts a leading `v` and tolerates
// trailing pre-release (`-rc.1`) / build metadata (`+build.5`) but
// only major.minor.patch participates in comparison. Returns `null`
@ -396,8 +465,8 @@ export async function chooseExecutableByMinVersion(
// (line ~390 below) and via `clearVersionAwareResolutionCache()`, so
// a missing or relocated cached pick still gets re-probed at the
// next launch.
const cached = versionAwareCache.get(def.id);
if (cached && existsSync(cached)) {
const cached = cachedVersionAwarePath(def.id);
if (cached) {
return cached;
}
@ -433,7 +502,7 @@ export async function chooseExecutableByMinVersion(
for (const probe of probes) {
const cmp = compareSemver(probe.version, def.minVersion);
if (cmp !== null && cmp >= 0) {
versionAwareCache.set(def.id, probe.path);
rememberVersionAwarePath(def.id, probe.path);
return probe.path;
}
}

View file

@ -317,6 +317,11 @@ import {
readMcpConfig,
writeMcpConfig,
} from './mcp-config.js';
import {
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
validateRunToolBundleForAgent,
} from './run-tool-bundle.js';
import {
beginAuth,
exchangeCodeForToken,
@ -10776,8 +10781,8 @@ export async function startServer({
// doesn't exist yet). Without one we don't pass cwd to spawn — the
// agent then runs in whatever inherited dir, which still lets API
// mode work but loses file-tool addressability.
// For git-linked projects (metadata.baseDir), use that folder directly
// so the agent writes back to the user's original source tree.
// Project directory resolution lives in projects.ts so sandbox mode can
// consistently reject imported-folder metadata that has no managed copy.
let cwd = null;
let existingProjectFiles = [];
if (typeof projectId === 'string' && projectId) {
@ -10902,57 +10907,71 @@ export async function startServer({
// values further down at .mcp.json write time — see the spawn block
// below — instead of re-reading.
let externalMcpConfig = { servers: [] };
try {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
} catch (err) {
console.warn(
'[mcp-config] read failed:',
err && err.message ? err.message : err,
);
if (!SANDBOX_RUNTIME.enabled) {
try {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
} catch (err) {
console.warn(
'[mcp-config] read failed:',
err && err.message ? err.message : err,
);
}
}
const enabledExternalMcp = externalMcpConfig.servers.filter((s) => s.enabled);
const runScopedMcpServers = Array.isArray(run?.toolBundle?.mcpServers)
? run.toolBundle.mcpServers
: [];
const {
enabledServers: enabledExternalMcp,
persistedTokenServerIds,
} = resolveExternalMcpServersForRun({
persistedServers: externalMcpConfig.servers,
runScopedServers: runScopedMcpServers,
sandboxMode: SANDBOX_RUNTIME.enabled,
});
const oauthTokensForSpawn = {};
try {
const stored = await readAllTokens(RUNTIME_DATA_DIR);
for (const [serverId, tok] of Object.entries(stored)) {
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue;
// Default to the persisted access token; null it out if expired so
// we never inject a stale `Authorization: Bearer …` header. The
// model treats a server with a Bearer pinned as connected and
// discourages re-auth, which is the worst possible UX when the
// token is going to 401 every call.
let access = isTokenExpired(tok) ? null : tok.accessToken;
if (isTokenExpired(tok) && tok.refreshToken) {
try {
const refreshed = await refreshAndPersistToken(
RUNTIME_DATA_DIR,
serverId,
tok,
);
if (refreshed) access = refreshed.accessToken;
} catch (err) {
if (persistedTokenServerIds.size > 0) {
try {
const stored = await readAllTokens(RUNTIME_DATA_DIR);
for (const [serverId, tok] of Object.entries(stored)) {
if (!persistedTokenServerIds.has(serverId)) continue;
// Default to the persisted access token; null it out if expired so
// we never inject a stale `Authorization: Bearer …` header. The
// model treats a server with a Bearer pinned as connected and
// discourages re-auth, which is the worst possible UX when the
// token is going to 401 every call.
let access = isTokenExpired(tok) ? null : tok.accessToken;
if (isTokenExpired(tok) && tok.refreshToken) {
try {
const refreshed = await refreshAndPersistToken(
RUNTIME_DATA_DIR,
serverId,
tok,
);
if (refreshed) access = refreshed.accessToken;
} catch (err) {
console.warn(
'[mcp-oauth] refresh failed for',
serverId,
err && err.message ? err.message : err,
);
}
}
if (access) {
oauthTokensForSpawn[serverId] = access;
} else {
console.warn(
'[mcp-oauth] refresh failed for',
'[mcp-oauth] skipping expired token for',
serverId,
err && err.message ? err.message : err,
'— reconnect required',
);
}
}
if (access) {
oauthTokensForSpawn[serverId] = access;
} else {
console.warn(
'[mcp-oauth] skipping expired token for',
serverId,
'— reconnect required',
);
}
} catch (err) {
console.warn(
'[mcp-tokens] read failed:',
err && err.message ? err.message : err,
);
}
} catch (err) {
console.warn(
'[mcp-tokens] read failed:',
err && err.message ? err.message : err,
);
}
const connectedExternalMcp = enabledExternalMcp
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
@ -11321,6 +11340,8 @@ export async function startServer({
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
),
agentLaunch,
)
@ -11703,6 +11724,8 @@ export async function startServer({
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
);
if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
@ -12656,6 +12679,7 @@ export async function startServer({
stderrTail: agentStderrTail,
stdoutTail: agentStdoutTail,
env: spawnedAgentEnv,
resolvedBin: agentLaunch.selectedPath,
});
// A non-zero exit whose output reads as an auth / quota / upstream
// problem (typical of Claude Code, codex, …) gets the specific code
@ -13001,14 +13025,33 @@ export async function startServer({
};
});
function runToolBundleDeliveryTargetForProject(projectId, metadata) {
if (typeof projectId !== 'string' || !projectId || !isSafeId(projectId)) {
return 'none';
}
try {
const cwd = resolveProjectDir(PROJECTS_DIR, projectId, metadata, {
allowUnavailableSandboxImportedProject: true,
});
return isManagedProjectCwd(cwd, PROJECTS_DIR) ? 'managed-project' : 'external-project';
} catch {
return 'none';
}
}
app.post('/api/runs', async (req, res) => {
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
if (!toolBundle.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
}
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
// before the run is created. The resolver returns null when the body
// does not mention a plugin (legacy runs unchanged), an error envelope
@ -13024,7 +13067,7 @@ export async function startServer({
// bundled scenario that is not installed leaves the run plugin-less,
// which matches the legacy path.
let resolvedSnapshot = null;
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
let registryView;
try {
registryView = await loadPluginRegistryView();
@ -13032,26 +13075,26 @@ export async function startServer({
return res.status(500).json({ error: String(err) });
}
const explicitPlugin =
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
let runResolveBody = req.body;
requestBody.pluginId || requestBody.appliedPluginSnapshotId;
let runResolveBody = requestBody;
if (!explicitPlugin) {
const projectRow = getProject(db, req.body.projectId);
const projectRow = getProject(db, requestBody.projectId);
const hasPin =
typeof projectRow?.appliedPluginSnapshotId === 'string'
&& projectRow.appliedPluginSnapshotId.length > 0;
if (!hasPin) {
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
runResolveBody = { ...requestBody, pluginId: fallbackPluginId };
}
}
}
const resolved = resolvePluginSnapshot({
db,
body: runResolveBody,
projectId: req.body.projectId,
conversationId: typeof req.body.conversationId === 'string'
? req.body.conversationId
projectId: requestBody.projectId,
conversationId: typeof requestBody.conversationId === 'string'
? requestBody.conversationId
: null,
registry: registryView,
connectorProbe: buildConnectorProbe(connectorService),
@ -13059,7 +13102,7 @@ export async function startServer({
if (resolved && !resolved.ok) {
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback skipped for run on project ${req.body.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
`[plugins] default-scenario fallback skipped for run on project ${requestBody.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
);
} else {
return res.status(resolved.status).json(resolved.body);
@ -13068,7 +13111,11 @@ export async function startServer({
resolvedSnapshot = resolved;
}
}
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
if (resolvedSnapshot?.ok) {
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
@ -13080,6 +13127,53 @@ export async function startServer({
if (renderedQuery.length > 0) meta.message = renderedQuery;
}
}
let runProject = null;
if (typeof meta.projectId === 'string' && meta.projectId) {
try {
runProject = getProject(db, meta.projectId);
assertSandboxProjectRootAvailable(runProject?.metadata);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
throw err;
}
}
// MCP / SDK callers may omit agentId. Resolve it before any run-create
// side effects so unsupported run-scoped tool bundles can fail cleanly.
if (typeof meta.agentId !== 'string' || !meta.agentId) {
try {
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const cfgAgentAvailable = cfgAgent
? agents.some((agent) => agent.id === cfgAgent && agent.available)
: false;
if (cfgAgent && cfgAgentAvailable) {
meta.agentId = cfgAgent;
} else {
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}
} catch (err) {
console.warn('[runs] agent id fallback failed', err);
}
}
const toolBundleSupport = validateRunToolBundleForAgent(
toolBundle.bundle,
typeof meta.agentId === 'string' ? getAgentDef(meta.agentId) : null,
{
deliveryTarget: runToolBundleDeliveryTargetForProject(
meta.projectId,
runProject?.metadata,
),
},
);
if (!toolBundleSupport.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
}
// MCP / SDK callers POST /api/runs with just a projectId — no
// conversationId, no pre-created assistantMessageId — because they
// don't know about OD's chat-row lifecycle. The web flow
@ -13139,30 +13233,6 @@ export async function startServer({
console.warn('[runs] mcp conversation fallback failed', err);
}
}
// MCP / SDK callers may omit agentId. Resolve it from the saved
// app-config agent (the user's configured default) or the first
// available CLI so the run does not immediately fail with
// "unknown agent: undefined" inside startChatRun.
if (typeof meta.agentId !== 'string' || !meta.agentId) {
try {
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const cfgAgentAvailable = cfgAgent
? agents.some((agent) => agent.id === cfgAgent && agent.available)
: false;
if (cfgAgent && cfgAgentAvailable) {
meta.agentId = cfgAgent;
} else {
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}
} catch (err) {
console.warn('[runs] agent id fallback failed', err);
}
}
const run = design.runs.create(meta);
try {
pinAssistantMessageOnRunCreate(db, run);
@ -13577,11 +13647,45 @@ export async function startServer({
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
if (!toolBundle.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
}
let chatProject = null;
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
try {
chatProject = getProject(db, requestBody.projectId);
assertSandboxProjectRootAvailable(chatProject?.metadata);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
throw err;
}
}
const toolBundleSupport = validateRunToolBundleForAgent(
toolBundle.bundle,
typeof requestBody.agentId === 'string' ? getAgentDef(requestBody.agentId) : null,
{
deliveryTarget: runToolBundleDeliveryTargetForProject(
requestBody.projectId,
chatProject?.metadata,
),
},
);
if (!toolBundleSupport.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
}
const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
const run = design.runs.create(meta);
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(meta, run));
@ -13658,6 +13762,7 @@ export async function startServer({
if (routine.target.mode === 'reuse') {
const project = getProject(db, routine.target.projectId);
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
assertSandboxProjectRootAvailable(project.metadata);
projectId = project.id;
projectName = project.name;
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;

View file

@ -86,6 +86,42 @@ async function withFakeAgent<T>(
}
}
async function withOnlyFakeAgent<T>(
binName: string,
script: string,
run: () => Promise<T>,
): Promise<T> {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
const oldClaudeBin = process.env.CLAUDE_BIN;
try {
if (process.platform === 'win32') {
const runner = path.join(dir, `${binName}-test-runner.cjs`);
await fsp.writeFile(runner, script);
await fsp.writeFile(
path.join(dir, `${binName}.cmd`),
`@echo off\r\nnode "${runner}" %*\r\n`,
);
} else {
const bin = path.join(dir, binName);
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
await fsp.chmod(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
delete process.env.CLAUDE_BIN;
return await run();
} finally {
process.env.PATH = oldPath;
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = oldAgentHome;
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
else process.env.CLAUDE_BIN = oldClaudeBin;
await fsp.rm(dir, { recursive: true, force: true });
}
}
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('codex', script, run);
}
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
return withFakeAgent('claude', script, run);
}
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
return withOnlyFakeAgent('openclaude', script, run);
}
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('opencode', script, run);
}
@ -2199,6 +2239,58 @@ process.stdin.on('end', () => {
);
});
it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => {
const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`);
const previousKey = process.env.ANTHROPIC_API_KEY;
try {
process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test';
await withOnlyFakeOpenClaude(
`
const fs = require('node:fs');
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
}));
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
JSON.parse(input.trim());
console.log(JSON.stringify({
type: 'assistant',
message: {
id: 'msg_1',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
},
}));
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
`,
async () => {
const result = await testAgentConnection({ agentId: 'claude' });
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'Claude Code',
});
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }),
);
expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i);
},
);
} finally {
if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = previousKey;
await fsp.rm(envFile, { force: true });
}
});
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
await withFakeClaude(
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,

View file

@ -1,6 +1,6 @@
import type http from 'node:http';
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { chmod, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@ -56,26 +56,6 @@ describe('POST /api/import/folder', () => {
}
}
async function waitForRunStatus(
runId: string,
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
let lastStatus = 'unknown';
for (let attempt = 0; attempt < 200; attempt += 1) {
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
const statusBody = (await statusResponse.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
lastStatus = statusBody.status;
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
return statusBody;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
}
it('creates a project rooted at the submitted folder', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -105,7 +85,7 @@ describe('POST /api/import/folder', () => {
});
});
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
it('rejects sandbox runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -123,15 +103,78 @@ describe('POST /api/import/folder', () => {
message: 'Inspect the imported project.',
}),
});
expect(runResp.status).toBe(202);
const { runId } = (await runResp.json()) as { runId: string };
const status = await waitForRunStatus(runId);
expect(status.status).toBe('failed');
expect(status.errorCode).toBe('BAD_REQUEST');
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
expect(runResp.status).toBe(400);
const body = (await runResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('rejects sandbox chat runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const chatResp = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: project.id,
message: 'Inspect the imported project.',
}),
});
expect(chatResp.status).toBe(400);
const body = (await chatResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
const runsResp = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(project.id)}`);
expect(runsResp.status).toBe(200);
const runsBody = (await runsResp.json()) as { runs: unknown[] };
expect(runsBody.runs).toHaveLength(0);
});
});
it('opens imported-folder projects through host editor routes in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const binDir = makeFolder();
const cursorBin = path.join(
binDir,
process.platform === 'win32' ? 'cursor.cmd' : 'cursor',
);
await writeFile(
cursorBin,
process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
);
await chmod(cursorBin, 0o755);
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
const previousPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`;
try {
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/open-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ editorId: 'cursor' }),
});
expect(resp.status).toBe(200);
const body = (await resp.json()) as { path?: string };
expect(body.path).toBe(await realpath(folder));
});
} finally {
if (previousPath == null) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
it('still opens an imported-folder project record in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');

View file

@ -68,10 +68,14 @@ process.exit(0);
async function waitForRunStatus(
baseUrl: string,
runId: string,
): Promise<{ status: string }> {
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
for (let attempt = 0; attempt < 200; attempt += 1) {
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
const body = (await r.json()) as { status: string };
const body = (await r.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
if (body.status !== 'queued' && body.status !== 'running') return body;
await new Promise((resolve) => setTimeout(resolve, 25));
}
@ -82,6 +86,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
let server: http.Server;
let baseUrl: string;
const projectsToClean: string[] = [];
const tempDirs: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
@ -106,9 +111,12 @@ describe('spawn writes external MCP config for Claude Code', () => {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servers: [] }),
}).catch(() => {});
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
async function createProject(): Promise<{ id: string; dir: string }> {
async function createProject(): Promise<{ id: string; dir: string; conversationId: string }> {
const id = `mcp-spawn-${randomUUID()}`;
const r = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
@ -116,6 +124,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
body: JSON.stringify({ id, name: id }),
});
expect(r.ok).toBe(true);
const body = (await r.json()) as { conversationId: string };
projectsToClean.push(id);
// The daemon owns its data dir; we discover the on-disk project path by
// having the daemon return the upload root, then composing path manually.
@ -123,7 +132,46 @@ describe('spawn writes external MCP config for Claude Code', () => {
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return { id, dir: join(projectsBase, id) };
return { id, dir: join(projectsBase, id), conversationId: body.conversationId };
}
async function importFolderProject(): Promise<{
id: string;
dir: string;
externalDir: string;
conversationId: string;
}> {
const externalDir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-import-'));
tempDirs.push(externalDir);
await fsp.writeFile(join(externalDir, 'index.html'), '<!doctype html>');
const r = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseDir: externalDir }),
});
expect(r.ok).toBe(true);
const body = (await r.json()) as { project: { id: string }; conversationId: string };
projectsToClean.push(body.project.id);
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return {
id: body.project.id,
dir: join(projectsBase, body.project.id),
externalDir,
conversationId: body.conversationId,
};
}
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
@ -197,6 +245,347 @@ describe('spawn writes external MCP config for Claude Code', () => {
});
}, 30_000);
it('fails sandbox runs for imported-folder projects before writing MCP config', async () => {
await withFakeClaude(async () => {
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
servers: [
{
id: 'sandbox-run',
transport: 'sse',
enabled: true,
url: 'https://mcp.example.test',
},
],
}),
});
expect(putRes.ok).toBe(true);
const { id, dir, externalDir, conversationId } = await importFolderProject();
await withSandboxMode(async () => {
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello sandbox mcp',
}),
});
expect(chatRes.status).toBe(400);
const body = (await chatRes.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
const managedTarget = join(dir, '.mcp.json');
expect(existsSync(managedTarget)).toBe(false);
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'hello sandbox mcp')).toBe(false);
});
}, 30_000);
it('rejects sandbox routine reuse of imported-folder projects before creating run state', async () => {
const { id } = await importFolderProject();
const conversationsBeforeRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
expect(conversationsBeforeRes.ok).toBe(true);
const conversationsBeforeBody = (await conversationsBeforeRes.json()) as {
conversations: Array<{ id: string }>;
};
const conversationIdsBefore = conversationsBeforeBody.conversations.map((conversation) => conversation.id);
let routineId: string | null = null;
try {
const createRoutineRes = await fetch(`${baseUrl}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Sandbox imported folder routine',
prompt: 'try to run inside an imported folder',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'reuse', projectId: id },
agentId: 'claude',
enabled: false,
}),
});
expect(createRoutineRes.status).toBe(201);
const createRoutineBody = (await createRoutineRes.json()) as {
routine: { id: string };
};
routineId = createRoutineBody.routine.id;
await withSandboxMode(async () => {
const runRoutineRes = await fetch(`${baseUrl}/api/routines/${routineId}/run`, {
method: 'POST',
});
expect(runRoutineRes.status).toBe(500);
const runRoutineBody = (await runRoutineRes.json()) as { error?: string };
expect(runRoutineBody.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
const routineRunsRes = await fetch(`${baseUrl}/api/routines/${routineId}/runs?limit=10`);
expect(routineRunsRes.ok).toBe(true);
const routineRunsBody = (await routineRunsRes.json()) as { runs: unknown[] };
expect(routineRunsBody.runs).toHaveLength(0);
const runsRes = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(id)}`);
expect(runsRes.ok).toBe(true);
const runsBody = (await runsRes.json()) as { runs: unknown[] };
expect(runsBody.runs).toHaveLength(0);
const conversationsAfterRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
expect(conversationsAfterRes.ok).toBe(true);
const conversationsAfterBody = (await conversationsAfterRes.json()) as {
conversations: Array<{ id: string }>;
};
expect(conversationsAfterBody.conversations.map((conversation) => conversation.id)).toEqual(
conversationIdsBefore,
);
} finally {
if (routineId) {
await fetch(`${baseUrl}/api/routines/${routineId}`, { method: 'DELETE' }).catch(() => {});
}
}
}, 30_000);
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
await withFakeClaude(async () => {
const { id, dir } = await createProject();
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello run-scoped mcp',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
args: ['run-tool.js'],
env: { RUN_ONLY: '1' },
},
{
id: 'run-remote',
transport: 'http',
enabled: true,
authMode: 'none',
url: 'https://example.test/mcp',
headers: { 'X-Run': 'ok' },
},
],
},
}),
});
expect(chatRes.status).toBe(202);
const { runId } = (await chatRes.json()) as { runId: string };
const status = await waitForRunStatus(baseUrl, runId) as {
status: string;
toolBundle?: { mcpServers?: Array<{ id: string }> };
};
expect(status.status).toBe('succeeded');
expect(status.toolBundle?.mcpServers?.map((server) => server.id)).toEqual([
'run-local',
'run-remote',
]);
const target = join(dir, '.mcp.json');
expect(existsSync(target)).toBe(true);
const written = JSON.parse(await fsp.readFile(target, 'utf8'));
expect(written.mcpServers.run_local).toBeUndefined();
expect(written.mcpServers['run-local']).toMatchObject({
command: 'node',
args: ['run-tool.js'],
env: { RUN_ONLY: '1' },
});
expect(written.mcpServers['run-remote']).toMatchObject({
type: 'http',
url: 'https://example.test/mcp',
headers: { 'X-Run': 'ok' },
});
const persistedRes = await fetch(`${baseUrl}/api/mcp/servers`);
expect(persistedRes.ok).toBe(true);
const persisted = (await persistedRes.json()) as { servers: unknown[] };
expect(persisted.servers).toEqual([]);
});
}, 30_000);
it('rejects Claude run-scoped MCP bundles for imported-folder projects', async () => {
const { id, dir, externalDir, conversationId } = await importFolderProject();
const runsRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'imported run-scoped tools',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(runsRes.status).toBe(400);
const runsBody = (await runsRes.json()) as { error?: { message?: string } };
expect(runsBody.error?.message).toContain('toolBundle requires a daemon-managed project');
const chatRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'imported chat-scoped tools',
toolBundle: {
mcpServers: [
{
id: 'run-local-chat',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(chatRes.status).toBe(400);
const chatBody = (await chatRes.json()) as { error?: { message?: string } };
expect(chatBody.error?.message).toContain('toolBundle requires a daemon-managed project');
expect(existsSync(join(dir, '.mcp.json'))).toBe(false);
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'imported run-scoped tools')).toBe(false);
expect(messagesBody.messages.some((msg) => msg.content === 'imported chat-scoped tools')).toBe(false);
});
it('rejects malformed run-scoped MCP bundles before creating runs', async () => {
const { id } = await createProject();
const invalidRunsRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'bad tools',
toolBundle: {
mcpServers: [
{
id: 'missing-command',
transport: 'stdio',
},
],
},
}),
});
expect(invalidRunsRes.status).toBe(400);
const runsBody = (await invalidRunsRes.json()) as { error?: { message?: string } };
expect(runsBody.error?.message).toContain('toolBundle.mcpServers[0] is invalid');
const invalidChatRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'bad tools',
toolBundle: 'bad',
}),
});
expect(invalidChatRes.status).toBe(400);
const chatBody = (await invalidChatRes.json()) as { error?: { message?: string } };
expect(chatBody.error?.message).toContain('toolBundle must be an object');
});
it('rejects run-scoped MCP bundles the selected runtime cannot receive', async () => {
const { id, conversationId } = await createProject();
const unsupportedRuntimeRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'codex',
projectId: id,
message: 'bad tools',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(unsupportedRuntimeRes.status).toBe(400);
const unsupportedRuntimeBody = (await unsupportedRuntimeRes.json()) as {
error?: { message?: string };
};
expect(unsupportedRuntimeBody.error?.message).toContain(
'Codex CLI (codex) does not support run-scoped MCP tool bundles',
);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'bad tools')).toBe(false);
const unsupportedTransportRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'hermes',
projectId: id,
message: 'bad remote tools',
toolBundle: {
mcpServers: [
{
id: 'run-remote',
transport: 'http',
url: 'https://example.test/mcp',
},
],
},
}),
});
expect(unsupportedTransportRes.status).toBe(400);
const unsupportedTransportBody = (await unsupportedTransportRes.json()) as {
error?: { message?: string };
};
expect(unsupportedTransportBody.error?.message).toContain(
'Hermes (hermes) only supports stdio run-scoped MCP servers',
);
});
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
// session/new params instead of `.mcp.json`. The `.mcp.json` write path

View file

@ -124,6 +124,95 @@ test('conversation latest run follows assistant message position', () => {
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
});
test('conversation summaries expose cumulative completed run duration', () => {
const db = createDb();
insertProject(db, {
id: 'project-duration',
name: 'project-duration',
createdAt: 1,
updatedAt: 1,
});
insertConversation(db, {
id: 'project-duration-conversation',
projectId: 'project-duration',
title: 'Duration test',
createdAt: 1,
updatedAt: 4,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-first',
role: 'assistant',
content: 'first done',
runId: 'project-duration-first-run',
runStatus: 'succeeded',
startedAt: 10_000,
endedAt: 40_000,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-running',
role: 'assistant',
content: 'still running',
runId: 'project-duration-running-run',
runStatus: 'running',
startedAt: 45_000,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-second',
role: 'assistant',
content: 'second done',
runId: 'project-duration-second-run',
runStatus: 'failed',
startedAt: 50_000,
endedAt: 125_000,
});
const listed = listConversations(db, 'project-duration')[0] as { totalDurationMs?: number };
const fetched = getConversation(db, 'project-duration-conversation') as { totalDurationMs?: number } | null;
assert.equal(listed.totalDurationMs, 105_000);
assert.equal(fetched?.totalDurationMs, 105_000);
});
test('conversation summaries include usage-only terminal run durations', () => {
const db = createDb();
insertProject(db, {
id: 'project-usage-duration',
name: 'project-usage-duration',
createdAt: 1,
updatedAt: 1,
});
insertConversation(db, {
id: 'project-usage-duration-conversation',
projectId: 'project-usage-duration',
title: 'Usage duration test',
createdAt: 1,
updatedAt: 4,
});
upsertMessage(db, 'project-usage-duration-conversation', {
id: 'project-usage-duration-imported',
role: 'assistant',
content: 'imported done',
runId: 'project-usage-duration-imported-run',
runStatus: 'succeeded',
events: [{ kind: 'usage', durationMs: 22_000 }],
});
upsertMessage(db, 'project-usage-duration-conversation', {
id: 'project-usage-duration-timestamped',
role: 'assistant',
content: 'timestamped done',
runId: 'project-usage-duration-timestamped-run',
runStatus: 'succeeded',
startedAt: 30_000,
endedAt: 60_000,
});
const listed = listConversations(db, 'project-usage-duration')[0] as { totalDurationMs?: number };
const fetched = getConversation(db, 'project-usage-duration-conversation') as { totalDurationMs?: number } | null;
assert.equal(listed.totalDurationMs, 52_000);
assert.equal(fetched?.totalDurationMs, 52_000);
});
test('conversation listing batches latest run summaries for large projects', () => {
const db = createDb();
insertProject(db, {

View file

@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import {
normalizeRunToolBundleForRun,
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
summarizeRunToolBundle,
validateRunToolBundleForAgent,
} from '../src/run-tool-bundle.js';
describe('run-scoped tool bundles', () => {
it('sanitizes MCP servers onto the run and redacts spawn-only details in summaries', () => {
const bundle = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'local-tools',
label: 'Local tools',
transport: 'stdio',
command: 'node',
args: ['server.js', '--token=secret'],
env: { API_TOKEN: 'secret' },
},
{
id: 'remote-tools',
transport: 'http',
url: 'https://example.test/mcp',
headers: { Authorization: 'Bearer secret' },
},
{
id: '../bad',
transport: 'stdio',
command: 'node',
},
],
});
expect(bundle.mcpServers).toHaveLength(2);
expect(bundle.mcpServers[0]).toMatchObject({
id: 'local-tools',
command: 'node',
env: { API_TOKEN: 'secret' },
});
const summary = summarizeRunToolBundle(bundle);
expect(summary).toEqual({
mcpServers: [
{
id: 'local-tools',
label: 'Local tools',
transport: 'stdio',
enabled: true,
},
{
id: 'remote-tools',
transport: 'http',
enabled: true,
authMode: 'oauth',
},
],
});
expect(JSON.stringify(summary)).not.toContain('secret');
expect(JSON.stringify(summary)).not.toContain('server.js');
});
it('uses only run-scoped MCP servers in sandbox mode', () => {
const persistedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'persisted',
transport: 'http',
url: 'https://persisted.example.test/mcp',
},
],
}).mcpServers;
const runScopedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'run-only',
transport: 'stdio',
command: 'node',
args: ['run-tool.js'],
},
],
}).mcpServers;
const selection = resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode: true,
});
expect(selection.enabledServers.map((server) => server.id)).toEqual(['run-only']);
expect([...selection.persistedTokenServerIds]).toEqual([]);
});
it('rejects malformed run-scoped MCP server entries for request payloads', () => {
expect(parseRunToolBundleForRequest('bad')).toEqual({
ok: false,
message: 'toolBundle must be an object',
});
expect(parseRunToolBundleForRequest({ mcpServers: 'bad' })).toEqual({
ok: false,
message: 'toolBundle.mcpServers must be an array',
});
expect(parseRunToolBundleForRequest({
mcpServers: [
{
id: 'missing-command',
transport: 'stdio',
},
],
})).toEqual({
ok: false,
message: 'toolBundle.mcpServers[0] is invalid',
});
expect(parseRunToolBundleForRequest({
mcpServers: [
{
id: 'dup',
transport: 'stdio',
command: 'node',
},
{
id: 'dup',
transport: 'http',
url: 'https://example.test/mcp',
},
],
})).toEqual({
ok: false,
message: 'toolBundle.mcpServers[1] duplicates server id "dup"',
});
});
it('lets a run-scoped server override persisted config without inheriting persisted tokens', () => {
const persistedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'shared',
transport: 'http',
url: 'https://persisted.example.test/mcp',
},
{
id: 'persisted-only',
transport: 'http',
url: 'https://persisted-only.example.test/mcp',
},
],
}).mcpServers;
const runScopedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'shared',
transport: 'http',
url: 'https://run.example.test/mcp',
headers: { Authorization: 'Bearer run-token' },
},
],
}).mcpServers;
const selection = resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode: false,
});
expect(selection.enabledServers).toHaveLength(2);
expect(selection.enabledServers.find((server) => server.id === 'shared')).toMatchObject({
url: 'https://run.example.test/mcp',
});
expect([...selection.persistedTokenServerIds]).toEqual(['persisted-only']);
});
it('rejects bundles for runtimes that cannot receive the requested servers', () => {
const stdioOnly = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'local',
transport: 'stdio',
command: 'node',
},
],
});
const remote = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'remote',
transport: 'http',
url: 'https://example.test/mcp',
},
],
});
expect(validateRunToolBundleForAgent(stdioOnly, {
id: 'codex',
name: 'Codex CLI',
})).toEqual({
ok: false,
message: 'Codex CLI (codex) does not support run-scoped MCP tool bundles',
});
expect(validateRunToolBundleForAgent(remote, {
id: 'hermes',
name: 'Hermes',
externalMcpInjection: 'acp-merge',
})).toEqual({
ok: false,
message:
'toolBundle.mcpServers[0] uses http transport, but Hermes (hermes) only supports stdio run-scoped MCP servers',
});
expect(validateRunToolBundleForAgent(remote, {
id: 'claude',
name: 'Claude Code',
externalMcpInjection: 'claude-mcp-json',
})).toEqual({ ok: true });
expect(validateRunToolBundleForAgent(remote, {
id: 'claude',
name: 'Claude Code',
externalMcpInjection: 'claude-mcp-json',
}, {
deliveryTarget: 'external-project',
})).toEqual({
ok: false,
message:
'Claude Code (claude) receives run-scoped MCP tool bundles through project .mcp.json, ' +
'so toolBundle requires a daemon-managed project',
});
});
});

View file

@ -80,6 +80,45 @@ describe('chat run service shutdown', () => {
});
});
it('stores a run-scoped tool bundle and returns a redacted status summary', () => {
const runs = createRuns();
const run = runs.create({
projectId: 'project-1',
conversationId: 'conv-a',
toolBundle: {
mcpServers: [
{
id: 'run-tools',
transport: 'stdio',
command: 'node',
args: ['server.js', '--token=secret'],
env: { API_TOKEN: 'secret' },
},
],
},
}) as any;
expect(run.toolBundle.mcpServers).toHaveLength(1);
expect(run.toolBundle.mcpServers[0]).toMatchObject({
id: 'run-tools',
command: 'node',
env: { API_TOKEN: 'secret' },
});
const status = runs.statusBody(run);
expect(status.toolBundle).toEqual({
mcpServers: [
{
id: 'run-tools',
transport: 'stdio',
enabled: true,
},
],
});
expect(JSON.stringify(status)).not.toContain('secret');
expect(JSON.stringify(status)).not.toContain('server.js');
});
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
const runs = createRuns();
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });

View file

@ -957,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => {
const env = spawnEnvForAgent(
'claude',
{
ANTHROPIC_API_KEY: 'sk-openclaude',
PATH: '/usr/bin',
},
{},
{},
{ resolvedBin: '/tools/openclaude' },
);
assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, {

View file

@ -19,6 +19,7 @@ import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
@ -373,6 +374,47 @@ describe('chooseExecutableByMinVersion (#978: skip stale binaries that fail the
rmSync(newDir, { recursive: true, force: true });
}
});
fsTest('cached pick replaced in place with an older binary is re-probed instead of returned stale', async () => {
const cachedDir = mkdtempSync(join(tmpdir(), 'od-cache-replace-cached-'));
const fallbackDir = mkdtempSync(join(tmpdir(), 'od-cache-replace-fallback-'));
try {
const cachedGemini = join(cachedDir, 'gemini');
const fallbackGemini = join(fallbackDir, 'gemini');
writeFileSync(cachedGemini, '0.40.1\n');
writeFileSync(fallbackGemini, '0.41.0\n');
chmodSync(cachedGemini, 0o755);
chmodSync(fallbackGemini, 0o755);
process.env.OD_AGENT_HOME = cachedDir;
process.env.PATH = `${cachedDir}${delimiter}${fallbackDir}`;
const def = minimalAgentDef({ id: 'gemini', bin: 'gemini', minVersion: '0.30.0' });
let probes = 0;
const runVersion = async (p: string) => {
probes += 1;
return readFileSync(p, 'utf8');
};
const firstChosen = await chooseExecutableByMinVersion(def, {}, { runVersion });
expect(firstChosen).toBe(cachedGemini);
const probesAfterCold = probes;
expect(probesAfterCold).toBeGreaterThanOrEqual(2);
// Simulate a package-manager rewrite at the same visible path:
// the path still exists, but it now points at an older build that
// should not keep bypassing the min-version gate until daemon restart.
rmSync(cachedGemini);
writeFileSync(cachedGemini, '0.1.12 downgraded in-place\n');
chmodSync(cachedGemini, 0o755);
const secondChosen = await chooseExecutableByMinVersion(def, {}, { runVersion });
expect(secondChosen).toBe(fallbackGemini);
expect(probes).toBeGreaterThan(probesAfterCold);
} finally {
rmSync(cachedDir, { recursive: true, force: true });
rmSync(fallbackDir, { recursive: true, force: true });
}
});
});
describe('inspectAgentExecutableResolution + minVersion cache wiring (#978)', () => {

View file

@ -2658,6 +2658,11 @@ function ToolsPluginsPanel({
<button
type="button"
className="composer-tools-row-main"
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 and the inserted token
// lands at the user's actual cursor position (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={async () => {
setPendingId(p.id);
try {
@ -2749,6 +2754,10 @@ function ToolsMcpPanel({
type="button"
role="menuitem"
className="composer-tools-row"
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={() => onInsert(s.id)}
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
>
@ -2839,6 +2848,10 @@ function ToolsSkillsPanel({
type="button"
role="menuitem"
className={`composer-tools-row${active ? ' active' : ''}`}
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={async () => {
setPendingId(skill.id);
try {

View file

@ -2014,6 +2014,16 @@ export function conversationMetaLabel(
t: TranslateFn,
): string {
const latestRun = conversation.latestRun;
if (
latestRun &&
(latestRun.status === 'succeeded' ||
latestRun.status === 'failed' ||
latestRun.status === 'canceled') &&
typeof conversation.totalDurationMs === 'number' &&
Number.isFinite(conversation.totalDurationMs)
) {
return formatDurationShort(conversation.totalDurationMs);
}
if (
latestRun &&
(latestRun.status === 'succeeded' ||

View file

@ -143,6 +143,14 @@ export type ManualEditPendingStyleSave = {
};
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
type PreviewCanvasSize = { width: number; height: number };
type CommentPreviewCanvasOptions = {
boardMode: boolean;
sidePanelCollapsed: boolean;
viewport?: PreviewViewportId;
};
type PreviewScaleOptions = {
canvasPadding?: number;
};
type PreviewViewportPreset = {
id: PreviewViewportId;
width: number | null;
@ -214,6 +222,18 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
},
];
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
const COMMENT_SIDE_DOCK_WIDTH = 320;
const COMMENT_SIDE_DOCK_RAIL_WIDTH = 42;
const COMMENT_SIDE_DOCK_GAP = 12;
const COMMENT_SIDE_DOCK_PADDING = 8;
const COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING = 24;
const COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH = 280;
const COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT = 220;
const COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT = 48;
const COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION =
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT;
const COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION =
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT;
// The five basic style facets the inspect panel exposes. Kept narrow on
// purpose — open-slide's design tokens panel only edits global tokens, so
@ -500,10 +520,11 @@ function previewViewportStyle(
viewport: PreviewViewportId,
previewScale = 1,
canvasSize?: PreviewCanvasSize,
options?: PreviewScaleOptions,
): CSSProperties & Record<string, string | number> {
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
if (!preset.width) return {};
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize);
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize, options);
return {
'--preview-viewport-width': `${preset.width}px`,
'--preview-viewport-height': `${preset.height}px`,
@ -512,15 +533,54 @@ function previewViewportStyle(
};
}
export function commentPreviewCanvasSize(
canvasSize: PreviewCanvasSize | undefined,
options: CommentPreviewCanvasOptions,
): PreviewCanvasSize | undefined {
if (!canvasSize || !options.boardMode) return canvasSize;
const dockPadding = options.viewport && options.viewport !== 'desktop'
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
: COMMENT_SIDE_DOCK_PADDING;
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
if (usesStackedCommentSideDock(canvasSize, options)) {
const stackedHeightDeduction = options.sidePanelCollapsed
? COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION
: COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION;
return {
width: Math.max(1, canvasSize.width - (COMMENT_SIDE_DOCK_PADDING * 2)),
height: Math.max(1, canvasSize.height - stackedHeightDeduction),
};
}
return {
width: Math.max(1, dockedWidth),
height: Math.max(1, canvasSize.height - (dockPadding * 2)),
};
}
function usesStackedCommentSideDock(
canvasSize: PreviewCanvasSize | undefined,
options: CommentPreviewCanvasOptions,
) {
if (!canvasSize || !options.boardMode) return false;
const dockPadding = options.viewport && options.viewport !== 'desktop'
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
: COMMENT_SIDE_DOCK_PADDING;
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
return dockedWidth < COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH;
}
export function effectivePreviewScale(
viewport: PreviewViewportId,
previewScale: number,
canvasSize?: PreviewCanvasSize,
options?: PreviewScaleOptions,
) {
if (viewport === 'desktop') return previewScale;
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
const canvasPadding = 48;
const canvasPadding = options?.canvasPadding ?? 48;
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
@ -2086,7 +2146,6 @@ export function CommentSidePanel({
activeCommentId,
collapsed,
onCollapsedChange,
onClose,
onToggleSelect,
onSelectAll,
onClearSelection,
@ -2102,7 +2161,6 @@ export function CommentSidePanel({
activeCommentId: string | null;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onClose: () => void;
onToggleSelect: (commentId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
@ -2119,21 +2177,48 @@ export function CommentSidePanel({
const selectedCount = visibleSelectedIds.size;
const allSelected = comments.length > 0 && selectedCount === comments.length;
const commentsLabel = t('chat.tabComments');
const collapsedRailRef = useRef<HTMLButtonElement | null>(null);
const expandedToggleRef = useRef<HTMLButtonElement | null>(null);
const pendingToggleFocusRef = useRef<'collapsed' | 'expanded' | null>(null);
const panelId = useId();
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
const submitNewComment = async () => {
if (!onCreateComment || !newCommentDraft.trim()) return;
const saved = await onCreateComment(newCommentDraft.trim());
if (saved) setNewCommentDraft('');
};
useEffect(() => {
const target =
pendingToggleFocusRef.current === 'collapsed'
? collapsedRailRef.current
: pendingToggleFocusRef.current === 'expanded'
? expandedToggleRef.current
: null;
if (!target) return;
pendingToggleFocusRef.current = null;
target.focus();
}, [collapsed]);
const handleCollapsedChange = (
nextCollapsed: boolean,
nextFocusTarget: 'collapsed' | 'expanded',
) => {
pendingToggleFocusRef.current = nextFocusTarget;
onCollapsedChange(nextCollapsed);
};
if (collapsed) {
return (
<button
ref={collapsedRailRef}
type="button"
className="comment-side-rail"
data-testid="comment-side-collapsed-rail"
aria-label={t('preview.showSidebar', { label: commentsLabel })}
aria-expanded={false}
title={t('preview.showSidebar', { label: commentsLabel })}
onClick={() => onCollapsedChange(false)}
onClick={() => handleCollapsedChange(false, 'expanded')}
>
<RemixIcon name="message-3-line" size={15} />
<span>{commentsLabel}</span>
@ -2143,7 +2228,7 @@ export function CommentSidePanel({
}
return (
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
<aside id={panelId} className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
<div className="comment-side-header">
<div className="comment-side-title">
<RemixIcon name="message-3-line" size={15} />
@ -2160,15 +2245,18 @@ export function CommentSidePanel({
{t('chat.comments.selectAll')}
</button>
) : null}
<button
type="button"
className="comment-side-close"
aria-label={t('common.close')}
title={t('common.close')}
onClick={onClose}
>
<Icon name="close" size={12} />
</button>
<button
ref={expandedToggleRef}
type="button"
className="comment-side-collapse"
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
aria-controls={panelId}
aria-expanded={true}
title={t('preview.hideSidebar', { label: commentsLabel })}
onClick={() => handleCollapsedChange(true, 'collapsed')}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
</div>
<div className="comment-side-list">
@ -2299,6 +2387,62 @@ export function CommentSidePanel({
);
}
function CommentSideDock({
comments,
selectedIds,
activeCommentId,
collapsed,
onCollapsedChange,
onToggleSelect,
onSelectAll,
onClearSelection,
onReply,
onSendSelected,
onCreateComment,
sending,
t,
composer,
}: {
comments: PreviewComment[];
selectedIds: Set<string>;
activeCommentId: string | null;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onToggleSelect: (commentId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
onReply: (comment: PreviewComment) => void;
onSendSelected: () => void | Promise<void>;
onCreateComment?: (note: string) => boolean | Promise<boolean>;
sending: boolean;
t: TranslateFn;
composer?: ReactNode;
}) {
return (
<div
className={`comment-side-dock${collapsed ? ' collapsed' : ''}`}
data-testid="comment-side-dock"
>
<CommentSidePanel
comments={comments}
selectedIds={selectedIds}
activeCommentId={activeCommentId}
collapsed={collapsed}
onCollapsedChange={onCollapsedChange}
onToggleSelect={onToggleSelect}
onSelectAll={onSelectAll}
onClearSelection={onClearSelection}
onReply={onReply}
onSendSelected={onSendSelected}
onCreateComment={onCreateComment}
sending={sending}
t={t}
composer={composer}
/>
</div>
);
}
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
@ -4309,6 +4453,17 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
const localCommentSideDockActive = commentPanelOpen && !commentPortalHost;
const boardPreviewCanvasSize = commentPreviewCanvasSize(previewBodySize, {
boardMode: localCommentSideDockActive,
sidePanelCollapsed: commentSidePanelCollapsed,
viewport: previewViewport,
});
const boardSideDockStacked = usesStackedCommentSideDock(previewBodySize, {
boardMode: localCommentSideDockActive,
sidePanelCollapsed: commentSidePanelCollapsed,
viewport: previewViewport,
});
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const [slideState, setSlideState] = useState<SlideState | null>(
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
);
const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize);
const overlayPreviewScale = overlayPreviewTransform.scale;
const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined;
const overlayPreviewScale = effectivePreviewScale(
previewViewport,
previewScale,
boardPreviewCanvasSize,
boardPreviewScaleOptions,
);
const overlayPreviewTransform: PreviewOverlayTransform = {
scale: overlayPreviewScale,
offsetX: 0,
offsetY: 0,
};
const shareRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => {
@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
};
const boardAvailable = mode === 'preview' && source !== null;
const showPreviewToolbarControls = mode === 'preview';
const commentPreviewLayoutClass = [
'comment-preview-layer',
localCommentSideDockActive ? 'comment-preview-layer-with-side-dock' : '',
localCommentSideDockActive && commentSidePanelCollapsed ? 'comment-preview-layer-dock-collapsed' : '',
boardSideDockStacked ? 'comment-preview-layer-side-dock-stacked' : '',
].filter(Boolean).join(' ');
const manualEditPanel = manualEditMode ? (
<ManualEditPanel
targets={manualEditTargets}
@ -6588,19 +6759,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
/>
) : null;
const commentSidePanel = commentPanelOpen ? (
<CommentSidePanel
<CommentSideDock
comments={visibleSideComments}
selectedIds={selectedSideCommentIds}
activeCommentId={activeSideCommentId}
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
onCollapsedChange={setCommentSidePanelCollapsed}
onClose={() => {
setCommentPanelOpen(false);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(false);
setBoardMode(false);
clearBoardComposer();
}}
onToggleSelect={(commentId) => {
setSelectedSideCommentIds((current) => {
const next = new Set(current);
@ -7102,201 +7266,258 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? (
<div
className={manualEditMode
? `manual-edit-workspace preview-viewport preview-viewport-${previewViewport}`
: [
'comment-preview-layer',
`preview-viewport preview-viewport-${previewViewport}`,
].filter(Boolean).join(' ')}
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
className={`${manualEditMode ? 'manual-edit-workspace' : commentPreviewLayoutClass} preview-viewport preview-viewport-${previewViewport}`}
data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
>
{manualEditPanel}
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
<div
style={
manualEditMode
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
: previewScaleShellStyle(previewViewport, previewScale)
}
>
<PreviewDrawOverlay
active={drawOverlayOpen}
onActiveChange={setDrawOverlayOpen}
captureTarget={null}
filePath={file.name}
sendDisabled={streaming}
sendDisabledReason={t('chat.annotationSendDisabledReason')}
<div
className={manualEditMode ? 'manual-edit-canvas' : 'comment-preview-canvas'}
data-testid={manualEditMode ? undefined : 'comment-preview-canvas'}
>
<div className={manualEditMode ? undefined : 'comment-frame-clip'}>
<div
style={
manualEditMode
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
: previewScaleShellStyle(previewViewport, previewScale)
}
>
<div className="artifact-preview-transport-stack">
{OD_PREVIEW_KEEP_ALIVE ? (
<PooledIframe
ref={urlPreviewIframeRef}
cacheKey={urlPreviewKeepAliveKey}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
) : (
<PreviewDrawOverlay
active={drawOverlayOpen}
onActiveChange={setDrawOverlayOpen}
captureTarget={null}
filePath={file.name}
sendDisabled={streaming}
sendDisabledReason={t('chat.annotationSendDisabledReason')}
>
<div className="artifact-preview-transport-stack">
{OD_PREVIEW_KEEP_ALIVE ? (
<PooledIframe
ref={urlPreviewIframeRef}
cacheKey={urlPreviewKeepAliveKey}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
) : (
<iframe
ref={urlPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
)}
<iframe
ref={urlPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
key={srcDocTransportResetKey}
ref={srcDocPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
data-od-render-mode="srcdoc"
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
aria-hidden={useUrlLoadPreview ? true : undefined}
tabIndex={useUrlLoadPreview ? -1 : 0}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
srcDoc={srcDocTransportContent}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
const frame = srcDocPreviewIframeRef.current;
if (!useUrlLoadPreview) iframeRef.current = frame;
// Reset the activation dedupe exactly ONCE per
// freshly mounted iframe DOM node, never on the
// subsequent load events that the same node
// emits during normal srcDoc rendering.
//
// The iframe's load event fires twice for one
// successful activation: once when the lazy
// transport shell HTML loads, and again when
// our own document.open/write/close inside the
// shell finishes. PR #2699 reset the dedupe on
// every load so that switching
// preview -> source -> preview (which remounts
// this iframe as a fresh DOM node) would
// re-activate the new shell. But resetting on
// every load also re-activated on the SECOND
// load of a non-remounted frame, which
// re-triggered document.open/write/close, which
// re-fired the load event, ad infinitum. The
// dedupe ref oscillated between null and the
// current srcDoc thousands of times per render
// and each iteration restarted every CSS
// animation from its `from` keyframe. Designs
// using `animation-fill-mode: both` with
// `from { opacity: 0 }` stayed at opacity 0
// forever and the preview read as blank.
// That is issue #2361.
//
// Tracking the last frame we reset for lets us
// keep PR #2699's "remount after Source toggle"
// fix while breaking the loop on plain renders.
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
srcDocFrameDedupeResetForRef.current = frame;
activatedSrcDocTransportHtmlRef.current = null;
}
if (useLazySrcDocTransport) setSrcDocShellReady(true);
activateLoadedSrcDocTransport(frame);
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
replayInspectOverridesToIframe(frame);
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
if (!useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
)}
<iframe
key={srcDocTransportResetKey}
ref={srcDocPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
data-od-render-mode="srcdoc"
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
aria-hidden={useUrlLoadPreview ? true : undefined}
tabIndex={useUrlLoadPreview ? -1 : 0}
title={file.name}
sandbox="allow-scripts allow-downloads"
srcDoc={srcDocTransportContent}
onLoad={() => {
const frame = srcDocPreviewIframeRef.current;
if (!useUrlLoadPreview) iframeRef.current = frame;
// Reset the activation dedupe exactly ONCE per
// freshly mounted iframe DOM node, never on the
// subsequent load events that the same node
// emits during normal srcDoc rendering.
//
// The iframe's load event fires twice for one
// successful activation: once when the lazy
// transport shell HTML loads, and again when
// our own document.open/write/close inside the
// shell finishes. PR #2699 reset the dedupe on
// every load so that switching
// preview -> source -> preview (which remounts
// this iframe as a fresh DOM node) would
// re-activate the new shell. But resetting on
// every load also re-activated on the SECOND
// load of a non-remounted frame, which
// re-triggered document.open/write/close, which
// re-fired the load event, ad infinitum. The
// dedupe ref oscillated between null and the
// current srcDoc thousands of times per render
// and each iteration restarted every CSS
// animation from its `from` keyframe. Designs
// using `animation-fill-mode: both` with
// `from { opacity: 0 }` stayed at opacity 0
// forever and the preview read as blank.
// That is issue #2361.
//
// Tracking the last frame we reset for lets us
// keep PR #2699's "remount after Source toggle"
// fix while breaking the loop on plain renders.
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
srcDocFrameDedupeResetForRef.current = frame;
activatedSrcDocTransportHtmlRef.current = null;
}
if (useLazySrcDocTransport) setSrcDocShellReady(true);
activateLoadedSrcDocTransport(frame);
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
replayInspectOverridesToIframe(frame);
syncBridgeModes(frame);
if (!useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
</div>
</PreviewDrawOverlay>
</div>
</PreviewDrawOverlay>
</div>
</div>
{boardMode ? (
<CommentPreviewOverlays
comments={commentCreateMode ? visibleSideComments : []}
liveTargets={liveCommentTargets}
hoveredTarget={hoveredCommentTarget}
hoveredPodMemberId={hoveredPodMemberId}
activeTarget={activeCommentTarget}
boardTool={boardTool}
showActivePin={commentCreateMode}
scale={overlayPreviewScale}
offsetX={overlayPreviewTransform.offsetX}
offsetY={overlayPreviewTransform.offsetY}
strokePoints={strokePoints}
onOpenComment={(comment, snapshot) => {
setCommentPanelOpen(true);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(true);
setBoardMode(true);
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setActivePreviewCommentId(comment.id);
setCommentDraft(comment.note);
setQueuedBoardNotes([]);
}}
/>
) : null}
{exportToast ? (
<div className="comment-toast-anchor">
<Toast
message={exportToast}
ttlMs={2200}
onDismiss={() => setExportToast(null)}
/>
</div>
) : null}
{commentSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={commentSavedToast}
ttlMs={2200}
onDismiss={() => setCommentSavedToast(null)}
/>
</div>
) : null}
{templateSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={templateSavedToast}
ttlMs={2200}
onDismiss={() => setTemplateSavedToast(null)}
/>
</div>
) : null}
{commentComposer}
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
) : null}
{/*
Hint banner for Inspect / Picker modes. The bridge in
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
with every element annotated with `data-od-id` /
`data-screen-label`, so `liveCommentTargets.size` is the
authoritative annotation count for the current artifact.
Two states:
- "has targets": the existing copy ("Click any element with
`data-od-id` to tune its style.") for users who just don't
see the crosshair cursor.
- "no targets" (issue #890): a freeform-generated artifact
(e.g. PRD HTML through a Claude-Code-compatible CLI
without a skill) ships zero `data-od-id` annotations. The
bridge's click handler walks up to <html>, finds nothing,
and bails clicks no-op silently. The static copy made
this look broken; the empty-state copy explains what's
missing and how to fix it. Mirrored across Inspect and
element-pick annotation mode because the failure surface is identical.
*/}
{inspectMode
&& openHintBox
&& !activeInspectTarget
&& !activeCommentTarget ? (
<div
className="inspect-empty-hint-container"
data-testid="inspect-empty-hint-container"
>
{liveCommentTargets.size === 0 ? (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint-no-targets"
>
{inspectMode
? t('chat.inspect.noEditableTargets')
: t('chat.inspect.noCommentTargets')}
</div>
) : (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint"
>
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
</div>
)}
<button
type="button"
title="Close Inspect Hint"
aria-label="Close Inspect Hint"
onClick={() => setOpenHintBox(false)}
className="orbit-artifact-ghost"
>
<Icon className="" name="close" size={12} />
</button>
</div>
) : null}
</div>
{boardMode ? (
<CommentPreviewOverlays
comments={commentCreateMode ? visibleSideComments : []}
liveTargets={liveCommentTargets}
hoveredTarget={hoveredCommentTarget}
hoveredPodMemberId={hoveredPodMemberId}
activeTarget={activeCommentTarget}
boardTool={boardTool}
showActivePin={commentCreateMode}
scale={overlayPreviewScale}
offsetX={overlayPreviewTransform.offsetX}
offsetY={overlayPreviewTransform.offsetY}
strokePoints={strokePoints}
onOpenComment={(comment, snapshot) => {
setCommentPanelOpen(true);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(true);
setBoardMode(true);
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setActivePreviewCommentId(comment.id);
setCommentDraft(comment.note);
setQueuedBoardNotes([]);
}}
/>
) : null}
{exportToast ? (
<div className="comment-toast-anchor">
<Toast
message={exportToast}
ttlMs={2200}
onDismiss={() => setExportToast(null)}
/>
</div>
) : null}
{commentSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={commentSavedToast}
ttlMs={2200}
onDismiss={() => setCommentSavedToast(null)}
/>
</div>
) : null}
{templateSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={templateSavedToast}
ttlMs={2200}
onDismiss={() => setTemplateSavedToast(null)}
/>
</div>
) : null}
{commentComposer}
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
) : null}
{commentPortalHost && commentSidePanel
? createPortal(commentSidePanel, commentPortalHost)
: commentPortalId
@ -7339,64 +7560,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
error={inspectError}
/>
) : null}
{/*
Hint banner for Inspect / Picker modes. The bridge in
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
with every element annotated with `data-od-id` /
`data-screen-label`, so `liveCommentTargets.size` is the
authoritative annotation count for the current artifact.
Two states:
- "has targets": the existing copy ("Click any element with
`data-od-id` to tune its style.") for users who just don't
see the crosshair cursor.
- "no targets" (issue #890): a freeform-generated artifact
(e.g. PRD HTML through a Claude-Code-compatible CLI
without a skill) ships zero `data-od-id` annotations. The
bridge's click handler walks up to <html>, finds nothing,
and bails clicks no-op silently. The static copy made
this look broken; the empty-state copy explains what's
missing and how to fix it. Mirrored across Inspect and
element-pick annotation mode because the failure surface is identical.
*/}
{inspectMode
&& openHintBox
&& !activeInspectTarget
&& !activeCommentTarget ? (
<div
className={`inspect-empty-hint-container${
commentPanelOpen && !commentSidePanelCollapsed ? ' comment-side-panel-open' : ''
}`}
data-testid="inspect-empty-hint-container"
>
{liveCommentTargets.size === 0 ? (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint-no-targets"
>
{inspectMode
? t('chat.inspect.noEditableTargets')
: t('chat.inspect.noCommentTargets')}
</div>
) : (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint"
>
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
</div>
)}
<button
type="button"
title="Close Inspect Hint"
aria-label="Close Inspect Hint"
onClick={() => setOpenHintBox(false)}
className="orbit-artifact-ghost"
>
<Icon className="" name="close" size={12} />
</button>
</div>
) : null}
</div>
) : (
<pre className="viewer-source">{source}</pre>

View file

@ -978,6 +978,53 @@
.live-artifact-preview-layer.preview-viewport[data-active='false'] {
display: none;
}
.comment-preview-layer {
--comment-side-dock-width: 320px;
--comment-side-dock-rail-width: 42px;
--comment-side-dock-stacked-height: 220px;
--comment-side-dock-stacked-rail-height: 48px;
}
.comment-preview-layer-with-side-dock {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-width);
gap: 12px;
padding: 8px;
overflow: hidden;
}
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock {
display: grid;
align-items: stretch;
justify-content: stretch;
padding: 24px;
overflow: hidden;
}
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-side-dock-stacked {
padding: 8px;
}
.comment-preview-layer-with-side-dock.comment-preview-layer-dock-collapsed {
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-rail-width);
}
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-height);
}
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked.comment-preview-layer-dock-collapsed {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-rail-height);
}
.comment-preview-canvas {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.comment-preview-layer-with-side-dock .comment-preview-canvas {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
}
.preview-viewport:not(.preview-viewport-desktop) {
overflow: auto;
display: flex;
@ -995,7 +1042,7 @@
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
.preview-viewport:not(.preview-viewport-desktop):not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
width: calc(var(--preview-viewport-width) * var(--preview-scale, 1));
height: calc(var(--preview-viewport-height) * var(--preview-scale, 1));
@ -1008,19 +1055,23 @@
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22);
background: var(--bg-panel);
}
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock .comment-preview-canvas {
width: auto;
height: auto;
}
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip > div,
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip > div,
.preview-viewport:not(.preview-viewport-desktop) .comment-preview-canvas > .comment-frame-clip > div,
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas > div {
will-change: transform;
}
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
position: relative;
inset: auto;
}
.preview-viewport-mobile .preview-frame-clip,
.preview-viewport-mobile .comment-frame-clip,
.preview-viewport-mobile:not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
.preview-viewport-mobile.manual-edit-workspace .manual-edit-canvas {
border-radius: 28px;
}
@ -1324,29 +1375,59 @@
color: var(--red);
}
/* Right-side comment thread panel. Shown while board (comment) mode
is on; takes the place of the chat sidebar's removed Comments tab.
Floats over the artifact preview at the right edge. */
is on; it docks beside the artifact preview so canvas clicks remain
available for placing comments. */
.comment-side-dock {
position: relative;
min-width: 0;
min-height: 0;
width: var(--comment-side-dock-width);
height: 100%;
display: flex;
}
.comment-side-dock.collapsed {
width: var(--comment-side-dock-rail-width);
}
.comment-preview-layer-side-dock-stacked .comment-side-dock {
width: 100%;
height: var(--comment-side-dock-stacked-height);
}
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed {
width: 100%;
height: var(--comment-side-dock-stacked-rail-height);
}
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail {
width: 100%;
height: 100%;
flex-direction: row;
justify-content: center;
padding: 0 12px;
gap: 12px;
}
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail span {
writing-mode: horizontal-tb;
}
.comment-side-panel {
--comment-accent: #ff5a3c;
--comment-accent-strong: color-mix(in srgb, var(--comment-accent) 78%, var(--text));
--comment-accent-surface: color-mix(in srgb, var(--comment-accent) 10%, var(--bg-panel));
--comment-accent-surface-strong: color-mix(in srgb, var(--comment-accent) 18%, var(--bg-panel));
--comment-accent-border: color-mix(in srgb, var(--comment-accent) 64%, var(--border));
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
position: relative;
width: 320px;
max-width: calc(100% - 16px);
height: 100%;
max-width: 100%;
background: var(--bg-panel, #fff);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
z-index: 30;
overflow: hidden;
}
.comment-preview-layer-side-dock-stacked .comment-side-panel {
width: 100%;
}
.comment-side-header {
display: flex;
align-items: center;
@ -1394,7 +1475,7 @@
cursor: default;
opacity: 0.45;
}
.comment-side-close {
.comment-side-collapse {
width: 26px;
height: 26px;
flex: 0 0 auto;
@ -1408,18 +1489,15 @@
color: var(--text-muted);
cursor: pointer;
}
.comment-side-close:hover {
.comment-side-collapse:hover {
background: var(--bg-subtle);
border-color: var(--border);
color: var(--text);
}
.comment-side-rail {
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
z-index: 30;
position: relative;
width: 42px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
@ -1689,16 +1767,15 @@
color: #fff;
}
/* Inspect panel sibling of the comment popover. Anchored to the
right side of the preview surface. Width is fixed so layout doesn't
reflow as the user scrubs slider values; controls reserve space for
their numeric readouts. */
/* Inspect panel sibling of the preview canvas and comment popover.
Keep the usual 296px width, but allow narrow board layouts to shrink it
inside the unclipped preview layer instead of losing controls. */
.inspect-panel {
position: absolute;
top: 14px;
right: 14px;
z-index: 5;
width: 296px;
width: min(296px, calc(100% - 28px));
max-height: calc(100% - 28px);
overflow-y: auto;
display: flex;
@ -1885,20 +1962,6 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
pointer-events: none;
}
.inspect-empty-hint-container.comment-side-panel-open {
right: 340px;
max-width: min(480px, calc(100% - 368px));
}
@media (max-width: 760px) {
.inspect-empty-hint-container.comment-side-panel-open {
left: 14px;
right: 14px;
top: 54px;
max-width: none;
}
}
.inspect-empty-hint-container button,
.inspect-empty-hint-container .close-button {
pointer-events: auto;

View file

@ -0,0 +1,193 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatComposer } from '../../src/components/ChatComposer';
// Regression coverage for #3195. The mention popover (typed `@`) prevents
// the textarea from losing focus on mousedown for every picker button —
// the comment at `ChatComposer.tsx:3039-3043` explains why: without it,
// `selectionStart` resets on the focus transfer and the insert handler
// targets the wrong substring (caret jumps to the start, the inserted
// token lands at offset 0 instead of at the user's cursor).
//
// The right-side `@`-button tools popover (`ToolsPluginsPanel`,
// `ToolsSkillsPanel`, `ToolsMcpPanel`) skips that protection — pick rows
// have `onClick` but no `onMouseDown={(e) => e.preventDefault()}`. So
// every insertion through the tools popover is at risk of the same caret
// reset whenever a real mouse triggers focus transfer first.
//
// We can't reliably observe the focus transfer in jsdom (it does not
// move focus on raw mousedown), so the test asserts the contract
// directly: the picker row must call `preventDefault()` on mousedown so
// that browsers never get to move focus before the click handler reads
// `textarea.selectionStart`.
const COMMUNITY_PLUGIN = {
id: 'sample-plugin',
title: 'Sample Plugin',
version: '1.0.0',
trust: 'restricted' as const,
sourceKind: 'bundled' as const,
source: 'bundled/sample',
capabilitiesGranted: [],
manifest: {
name: 'sample-plugin',
title: 'Sample Plugin',
description: 'Sample',
od: { kind: 'skill' },
},
fsPath: '/plugins/sample',
installedAt: 0,
updatedAt: 0,
};
const SKILL = {
id: 'deck-builder',
name: 'Deck Builder',
description: 'Build a polished slide deck.',
triggers: ['deck'],
mode: 'deck' as const,
previewType: 'html',
designSystemRequired: false,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: 'Make a deck',
aggregatesExamples: false,
};
const MCP_SERVER = {
id: 'slack',
label: 'Slack MCP',
transport: 'stdio' as const,
enabled: true,
command: 'slack-mcp',
};
let fetchMock: ReturnType<typeof vi.fn>;
let plugins = [COMMUNITY_PLUGIN];
let skills = [SKILL];
let servers = [MCP_SERVER];
function renderComposer(
overrides: Partial<ComponentProps<typeof ChatComposer>> = {},
) {
return render(
<ChatComposer
projectId="project-1"
projectFiles={[]}
streaming={false}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
onOpenMcpSettings={vi.fn()}
skills={skills}
{...overrides}
/>,
);
}
beforeEach(() => {
plugins = [COMMUNITY_PLUGIN];
skills = [SKILL];
servers = [MCP_SERVER];
fetchMock = vi.fn(async (url: string) => {
if (url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers, templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/plugins') {
return new Response(JSON.stringify({ plugins }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/skills') {
return new Response(JSON.stringify({ skills }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects/project-1') {
return new Response(JSON.stringify({ project: { id: 'project-1', skillId: SKILL.id } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
async function openToolsPopover() {
const trigger = document.querySelector(
'.composer-tools-trigger',
) as HTMLButtonElement | null;
expect(trigger).toBeTruthy();
fireEvent.click(trigger!);
await waitFor(() => expect(screen.getByRole('menu')).toBeTruthy());
}
function selectTab(label: string) {
const tab = Array.from(
document.querySelectorAll<HTMLButtonElement>('.composer-tools-tab'),
).find((el) => el.textContent?.trim() === label);
expect(tab).toBeTruthy();
fireEvent.click(tab!);
}
function rowByText(text: string): HTMLButtonElement {
// Skill / MCP rows are themselves the picker `<button>` (.composer-tools-row).
// Plugin rows wrap two buttons inside a `<div>`; the picker is the
// `.composer-tools-row-main` child, so prefer that selector first.
const row = Array.from(
document.querySelectorAll<HTMLButtonElement>('button.composer-tools-row-main, button.composer-tools-row'),
).find((btn) => btn.textContent?.includes(text));
expect(row).toBeTruthy();
return row!;
}
describe('ChatComposer tools-menu picker mousedown protection (#3195)', () => {
it('the skills picker prevents default on mousedown so the caret survives focus transfer', async () => {
renderComposer();
await openToolsPopover();
selectTab('Skills');
const row = rowByText('Deck Builder');
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
row.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('the plugins picker prevents default on mousedown so the caret survives focus transfer', async () => {
renderComposer();
await openToolsPopover();
selectTab('Plugins');
const row = rowByText('Sample Plugin');
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
row.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('the MCP picker prevents default on mousedown so the caret survives focus transfer', async () => {
renderComposer();
await openToolsPopover();
selectTab('MCP');
const row = rowByText('Slack MCP');
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
row.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
});

View file

@ -29,6 +29,7 @@ import {
LiveArtifactRefreshHistoryPanel,
SvgViewer,
applyInspectOverridesToSource,
commentPreviewCanvasSize,
effectivePreviewScale,
parseInspectOverridesFromSource,
previewOverlayTransform,
@ -75,6 +76,30 @@ function deferredResponse() {
return { promise, resolve };
}
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
return calls
.map(([message]) => message)
.filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => {
if (typeof message !== 'object' || message === null) return false;
const data = message as { type?: unknown; html?: unknown };
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
});
}
function testRect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
width,
height,
top,
left,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
} as DOMRect;
}
function clickAgentTool(testId: string) {
fireEvent.click(screen.getByTestId(testId));
}
@ -95,7 +120,10 @@ describe('FileViewer preview scale', () => {
'.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas',
);
expect(css).toMatch(
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\) \.comment-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\):not\(\.comment-preview-layer-with-side-dock\) \.comment-preview-canvas,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*width: calc\(var\(--preview-viewport-width\) \* var\(--preview-scale, 1\)\);/,
);
expect(css).toMatch(
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
);
});
@ -121,6 +149,60 @@ describe('FileViewer preview scale', () => {
expect(effectivePreviewScale('tablet', 1.25, { width: 820, height: 700 })).toBeLessThan(1);
});
it('uses the reduced board canvas size when the side dock is open', () => {
const dockedCanvas = commentPreviewCanvasSize(
{ width: 900, height: 700 },
{ boardMode: true, sidePanelCollapsed: false },
);
expect(dockedCanvas).toEqual({ width: 552, height: 684 });
expect(effectivePreviewScale('tablet', 1, dockedCanvas)).toBeLessThan(
effectivePreviewScale('tablet', 1, { width: 900, height: 700 }),
);
});
it('uses stacked canvas sizing for narrow board panes instead of a 1px docked canvas', () => {
const narrowCanvas = commentPreviewCanvasSize(
{ width: 400, height: 700 },
{ boardMode: true, sidePanelCollapsed: false },
);
expect(narrowCanvas).toEqual({ width: 384, height: 452 });
});
it('subtracts only the collapsed stacked rail height when the side dock is collapsed in the stacked layout', () => {
const expandedStackedCanvas = commentPreviewCanvasSize(
{ width: 300, height: 700 },
{ boardMode: true, sidePanelCollapsed: false },
);
const collapsedStackedCanvas = commentPreviewCanvasSize(
{ width: 300, height: 700 },
{ boardMode: true, sidePanelCollapsed: true },
);
expect(expandedStackedCanvas).toEqual({ width: 284, height: 452 });
expect(collapsedStackedCanvas).toEqual({ width: 284, height: 624 });
expect(collapsedStackedCanvas!.height).toBeGreaterThan(expandedStackedCanvas!.height);
});
it('matches the rendered non-desktop dock padding in board canvas sizing', () => {
const dockedCanvas = commentPreviewCanvasSize(
{ width: 900, height: 700 },
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
);
expect(dockedCanvas).toEqual({ width: 520, height: 652 });
});
it('fits non-desktop board previews against the inner canvas without subtracting viewport padding again', () => {
const dockedCanvas = commentPreviewCanvasSize(
{ width: 900, height: 700 },
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
);
expect(effectivePreviewScale('tablet', 1, dockedCanvas, { canvasPadding: 0 })).toBeCloseTo(652 / 1180);
});
it('offsets tablet and mobile overlays to the centered viewport card', () => {
expect(previewOverlayTransform('desktop', 1.25, { width: 1200, height: 800 })).toEqual({
scale: 1.25,
@ -2034,23 +2116,69 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
});
it('exits comment mode when closing the comments side panel', () => {
const openComment: PreviewComment = {
id: 'comment-open',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: 'preview.html',
elementId: 'pin-open',
selector: '[data-od-pin="pin-open"]',
label: 'pin-open',
text: '',
htmlHint: '',
position: { x: 24, y: 32, width: 18, height: 18 },
note: 'Open comment',
status: 'open',
createdAt: Date.now(),
updatedAt: Date.now(),
};
it('keeps the picker hint inside the canvas and clear of the open comment side panel', () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
const canvas = screen.getByTestId('comment-preview-canvas');
const dock = screen.getByTestId('comment-side-dock');
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
fireEvent.click(screen.getByRole('button', { name: /hide comments/i }));
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
expect(screen.getByTestId('comment-side-collapsed-rail')).toBeTruthy();
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
});
it('keeps non-docked tablet comment-tool previews fitted to the padded canvas', async () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
return testRect(0, 0, 0, 0);
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
fireEvent.click(screen.getByLabelText('Preview viewport'));
fireEvent.click(screen.getByRole('option', { name: 'Tablet' }));
clickAgentTool('board-mode-toggle');
const layout = screen.getByTestId('comment-preview-layout');
await waitFor(() => {
expect(layout.className).not.toContain('comment-preview-layer-with-side-dock');
expect(Number(layout.style.getPropertyValue('--preview-scale'))).toBeCloseTo((700 - 48) / 1180);
});
});
it('docks the comment side panel outside the clickable preview canvas', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
if (this.dataset.testid === 'comment-preview-canvas') return testRect(8, 8, 552, 684);
if (this.dataset.testid === 'comment-side-dock') return testRect(572, 8, 320, 684);
if (this.dataset.testid === 'comment-side-panel') return testRect(572, 8, 320, 684);
return testRect(0, 0, 0, 0);
});
render(
<FileViewer
@ -2058,22 +2186,53 @@ describe('FileViewer tweaks toolbar', () => {
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
previewComments={[openComment]}
/>,
);
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('comment-saved-marker-pin-open')).toBeTruthy();
const canvas = screen.getByTestId('comment-preview-canvas');
const dock = screen.getByTestId('comment-side-dock');
const panel = screen.getByTestId('comment-side-panel');
const canvasBox = canvas.getBoundingClientRect();
const dockBox = dock.getBoundingClientRect();
const panelBox = panel.getBoundingClientRect();
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
expect(dock.contains(panel)).toBe(true);
expect(canvas.contains(panel)).toBe(false);
expect(screen.getByTestId('comment-preview-layout').className).toContain(
'comment-preview-layer-with-side-dock',
);
expect(dockBox.left).toBeGreaterThanOrEqual(canvasBox.right);
expect(panelBox.left).toBeGreaterThanOrEqual(canvasBox.right);
});
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
expect(screen.queryByTestId('comment-saved-marker-pin-open')).toBeNull();
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
it('uses the narrow board layout when docking would leave too little canvas', async () => {
const getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
if (this.classList.contains('viewer-body')) return testRect(0, 0, 400, 700);
return testRect(0, 0, 0, 0);
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
await waitFor(() => {
expect(screen.getByTestId('comment-preview-layout').className).toContain(
'comment-preview-layer-side-dock-stacked',
);
});
getBoundingClientRectSpy.mockRestore();
});
it('keeps saved comment pins visible while adding another comment', async () => {
@ -2543,16 +2702,14 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByText('Do not recreate this stale comment')).toBeNull();
});
it('closes the comment side panel from the header close button', () => {
it('moves focus between comment side panel toggles when collapsing and expanding without a pre-focused click target', async () => {
const onCollapseChange = vi.fn();
const onClose = vi.fn();
const onSelectAll = vi.fn();
const onReply = vi.fn();
function Harness() {
const [collapsed, setCollapsed] = useState(false);
const [open, setOpen] = useState(true);
return open ? (
return (
<CommentSidePanel
comments={[
{
@ -2579,10 +2736,6 @@ describe('FileViewer tweaks toolbar', () => {
onCollapseChange(next);
setCollapsed(next);
}}
onClose={() => {
onClose();
setOpen(false);
}}
onToggleSelect={() => {}}
onSelectAll={onSelectAll}
onClearSelection={() => {}}
@ -2591,7 +2744,7 @@ describe('FileViewer tweaks toolbar', () => {
sending={false}
t={t}
/>
) : null;
);
}
render(<Harness />);
@ -2603,13 +2756,69 @@ describe('FileViewer tweaks toolbar', () => {
fireEvent.click(screen.getByText('不要github换成微信').closest('[data-testid="comment-side-item"]')!);
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'comment-1' }));
fireEvent.click(screen.getByRole('button', { name: /close/i }));
const hideComments = screen.getByRole('button', { name: /hide comments/i });
expect(onClose).toHaveBeenCalledOnce();
expect(onCollapseChange).not.toHaveBeenCalled();
fireEvent.click(hideComments);
expect(onCollapseChange).toHaveBeenLastCalledWith(true);
expect(screen.queryByText('不要github换成微信')).toBeNull();
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
const showComments = screen.getByTestId('comment-side-collapsed-rail');
await waitFor(() => expect(document.activeElement).toBe(showComments));
fireEvent.click(showComments);
expect(onCollapseChange).toHaveBeenLastCalledWith(false);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole('button', { name: /hide comments/i }));
});
});
it('announces comment side dock disclosure state without pointing at an unmounted panel', () => {
function Harness() {
const [collapsed, setCollapsed] = useState(false);
return (
<CommentSidePanel
comments={[]}
selectedIds={new Set()}
activeCommentId={null}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
onToggleSelect={() => {}}
onSelectAll={() => {}}
onClearSelection={() => {}}
onReply={() => {}}
onSendSelected={() => {}}
sending={false}
t={t}
/>
);
}
render(<Harness />);
const panel = screen.getByTestId('comment-side-panel');
const hideComments = screen.getByRole('button', { name: /hide comments/i });
const panelId = panel.id;
expect(panelId).toBeTruthy();
expect(hideComments.getAttribute('aria-controls')).toBe(panelId);
expect(hideComments.getAttribute('aria-expanded')).toBe('true');
fireEvent.click(hideComments);
const showComments = screen.getByTestId('comment-side-collapsed-rail');
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
expect(document.getElementById(panelId)).toBeNull();
expect(showComments.getAttribute('aria-controls')).toBeNull();
expect(showComments.getAttribute('aria-expanded')).toBe('false');
});
it('lets the inspect panel shrink inside narrow preview layouts', () => {
const css = readFileSync(join(process.cwd(), 'src/styles/viewer/core.css'), 'utf8');
const rule = css.match(/\.inspect-panel\s*\{[^}]+\}/)?.[0] ?? '';
expect(rule).toContain('width: min(296px, calc(100% - 28px));');
});
it('does not classify text labels containing a standalone article as links', () => {
@ -2637,7 +2846,6 @@ describe('FileViewer tweaks toolbar', () => {
activeCommentId={null}
collapsed={false}
onCollapsedChange={() => {}}
onClose={() => {}}
onToggleSelect={() => {}}
onSelectAll={() => {}}
onClearSelection={() => {}}

View file

@ -132,4 +132,27 @@ describe('conversation timestamps', () => {
expect(conversationMetaLabel(conversation, t as never)).toBe('15s');
});
it('prefers cumulative conversation duration over the latest run duration', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T14:00:00Z'));
const t = (key: string, vars?: Record<string, string | number>) =>
key === 'common.minutesShort' ? `${vars?.n}m` : key;
const conversation = {
id: 'conv-1',
projectId: 'project-1',
title: 'Multi-run session',
createdAt: Date.parse('2025-01-15T12:00:00Z'),
updatedAt: Date.parse('2025-01-15T12:03:00Z'),
totalDurationMs: 85_000,
latestRun: {
status: 'succeeded',
startedAt: 120_000,
endedAt: 130_000,
durationMs: 10_000,
},
} satisfies Conversation & { totalDurationMs: number };
expect(conversationMetaLabel(conversation, t as never)).toBe('1m 25s');
});
});

View file

@ -10,5 +10,5 @@
# 2. Run the relevant nix build/flake check
# 3. Copy the expected hash printed by Nix into the matching field below
daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo=";
webHash = "sha256-QOufFb3Hb5js3jK6QEl3WfnxNAa4DdZfMKoALTHY4hI=";
webHash = "sha256-IlXE7iNoT/+mcVbtzhJdcP5fNs7Hk8AYZMxfJ33dXck=";
}

View file

@ -7,7 +7,7 @@
"description": "Local-first design product: detects your installed code-agent CLI, runs design skills + design systems, streams artifacts into a sandboxed preview.",
"license": "Apache-2.0",
"bin": {
"od": "./apps/daemon/dist/cli.js"
"od": "./apps/daemon/bin/od.mjs"
},
"scripts": {
"postinstall": "node ./scripts/postinstall.mjs",
@ -15,7 +15,7 @@
"tools-pack": "pnpm exec tools-pack",
"tools-serve": "pnpm exec tools-serve",
"nix:update-hash": "node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts",
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts",
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts scripts/postinstall.test.ts",
"i18n:check": "tsx ./scripts/i18n-check.ts",
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts",
@ -25,6 +25,7 @@
"typecheck": "pnpm -r --workspace-concurrency=4 --if-present run typecheck && tsc -p scripts/tsconfig.json --noEmit"
},
"devDependencies": {
"@open-design/daemon": "workspace:*",
"@open-design/tools-dev": "workspace:*",
"@open-design/tools-pack": "workspace:*",
"@open-design/tools-serve": "workspace:*",

View file

@ -9,6 +9,7 @@ import type {
import type { ResearchOptions } from './research';
import type { RunContextSelection } from './context.js';
import type { MediaExecutionPolicy } from './media.js';
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp';
export type ChatRole = 'user' | 'assistant';
export type ChatCommentSelectionKind = PreviewCommentSelectionKind | 'visual';
@ -45,6 +46,12 @@ export interface ChatRequest {
* local providers.
*/
mediaExecution?: MediaExecutionPolicy;
/**
* Run-scoped tool bundle supplied by an orchestrator such as Muginn.
* These servers are made available only to the spawned agent for this run
* and are never written into the persistent Settings MCP registry.
*/
toolBundle?: RunScopedToolBundle;
/**
* Optional analytics context for the v2 run_created / run_finished
* events. The daemon never trusts these for behavior they only
@ -109,6 +116,31 @@ export interface ChatAnalyticsHints {
designSystemRunContext?: ChatAnalyticsDesignSystemRunContext;
}
export interface RunScopedMcpServerConfig extends Omit<McpServerConfig, 'enabled'> {
/**
* Omitted means enabled for this run. The daemon normalizes run-scoped
* inputs through the same sanitizer as persisted MCP config, but callers
* should not need to send persisted-settings boilerplate for disposable
* tool bundles.
*/
enabled?: boolean;
}
export interface RunScopedToolBundle {
mcpServers?: RunScopedMcpServerConfig[];
}
export interface RunScopedToolBundleSummary {
mcpServers: Array<{
id: string;
label?: string;
templateId?: string;
transport: McpTransport;
enabled: boolean;
authMode?: McpAuthMode;
}>;
}
export interface ChatRunCreateRequest extends ChatRequest {
projectId: string;
conversationId: string;
@ -130,6 +162,8 @@ export interface McpRunCreateRequest {
pluginId?: string;
model?: string;
pluginInputs?: Record<string, unknown>;
mediaExecution?: MediaExecutionPolicy;
toolBundle?: RunScopedToolBundle;
}
export type ChatRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
@ -219,6 +253,8 @@ export interface ChatRunStatusResponse {
eventsLogPath?: string | null;
/** Present on daemon run status responses that know the effective run policy. */
mediaExecution?: MediaExecutionPolicy;
/** Run-scoped tool bundle summary with secrets and command details redacted. */
toolBundle?: RunScopedToolBundleSummary;
}
export interface ChatRunListResponse {

View file

@ -195,6 +195,7 @@ export interface Conversation {
title: string | null;
createdAt: number;
updatedAt: number;
totalDurationMs?: number;
latestRun?: {
status: ChatRunStatus;
startedAt?: number;

View file

@ -19,6 +19,9 @@ importers:
.:
devDependencies:
'@open-design/daemon':
specifier: workspace:*
version: link:apps/daemon
'@open-design/tools-dev':
specifier: workspace:*
version: link:tools/dev

View file

@ -70,6 +70,8 @@ const residualAllowedExactPaths = new Set([
// executed directly by Node and are not loaded by the app runtime.
"scripts/import-prompt-templates.mjs",
"scripts/postinstall.mjs",
// Checked-in bin shim so pnpm can link `od` before daemon dist output exists.
"apps/daemon/bin/od.mjs",
"apps/packaged/esbuild.config.mjs",
// Browser service workers must be served as JavaScript files.
"apps/web/public/od-notifications-sw.js",

View file

@ -17,6 +17,7 @@ const buildTargets = [
"packages/sidecar-proto",
"packages/sidecar",
"packages/diagnostics",
"apps/daemon",
"tools/dev",
"tools/pack",
"tools/serve",

159
scripts/postinstall.test.ts Normal file
View file

@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
const repoRoot = join(import.meta.dirname, "..");
function readJson(path: string): unknown {
return JSON.parse(readFileSync(join(repoRoot, path), "utf8"));
}
function readPackageJson(path: string): Record<string, unknown> {
const manifest = readJson(path);
assert(typeof manifest === "object" && manifest !== null);
return manifest as Record<string, unknown>;
}
function packageName(manifest: unknown): string {
assert(typeof manifest === "object" && manifest !== null);
const name = (manifest as { name?: unknown }).name;
assert(typeof name === "string");
return name;
}
function packageBinTargets(manifest: unknown): string[] {
assert(typeof manifest === "object" && manifest !== null);
const bin = (manifest as { bin?: unknown }).bin;
if (typeof bin === "string") return [bin];
if (typeof bin !== "object" || bin === null) return [];
return Object.values(bin).filter((value): value is string => typeof value === "string");
}
function dependencySpecifier(manifest: Record<string, unknown>, name: string): string | undefined {
const dependencyFields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const;
for (const field of dependencyFields) {
const dependencies = manifest[field];
if (typeof dependencies !== "object" || dependencies === null) continue;
const specifier = (dependencies as Record<string, unknown>)[name];
if (typeof specifier === "string") return specifier;
}
return undefined;
}
function distDelegatingBinTargets(directory: string, manifest: unknown): string[] {
return packageBinTargets(manifest).filter((binTarget) => {
if (binTarget.startsWith("./dist/")) return true;
const source = readFileSync(join(repoRoot, directory, binTarget), "utf8");
return source.includes("../dist/") || source.includes("./dist/") || source.includes("/dist/");
});
}
function workspaceDependencyNames(manifest: unknown, includeDevDependencies = false): Set<string> {
assert(typeof manifest === "object" && manifest !== null);
const dependencyFields = includeDevDependencies
? ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
: ["dependencies", "optionalDependencies", "peerDependencies"];
const names = new Set<string>();
for (const field of dependencyFields) {
const dependencies = (manifest as Record<string, unknown>)[field];
if (typeof dependencies !== "object" || dependencies === null) continue;
for (const [name, version] of Object.entries(dependencies)) {
if (typeof version === "string" && version.startsWith("workspace:")) {
names.add(name);
}
}
}
return names;
}
function postinstallBuildTargets(): Set<string> {
const source = readFileSync(join(repoRoot, "scripts/postinstall.mjs"), "utf8");
const targets = [...source.matchAll(/"([^"]+)"/g)]
.map((match) => match[1])
.filter((value): value is string => value != null && /^(?:apps|packages|tools)\//.test(value));
return new Set(targets);
}
function workspacePackageDirectories(): string[] {
const scopedPackageDirectories = ["apps", "packages", "tools"].flatMap((scope) =>
readdirSync(join(repoRoot, scope), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => `${scope}/${entry.name}`),
);
return ["e2e", ...scopedPackageDirectories]
.filter((directory) => existsSync(join(repoRoot, directory, "package.json")))
.sort();
}
test("workspace bin entries use checked-in targets so pnpm can link them before postinstall", () => {
const manifests = new Map(
workspacePackageDirectories().map((directory) => [
directory,
readJson(`${directory}/package.json`),
]),
);
const consumedWorkspacePackages = new Set<string>();
for (const manifest of manifests.values()) {
for (const name of workspaceDependencyNames(manifest)) {
consumedWorkspacePackages.add(name);
}
}
const unlinkableBins = [...manifests.entries()]
.filter(([, manifest]) => consumedWorkspacePackages.has(packageName(manifest)))
.flatMap(([directory, manifest]) =>
packageBinTargets(manifest).map((binTarget) => ({
binTarget,
directory,
resolvedPath: join(repoRoot, directory, binTarget),
})),
)
.filter(({ resolvedPath }) => !existsSync(resolvedPath))
.map(({ binTarget, directory }) => `${directory}:${binTarget}`);
assert.deepEqual(unlinkableBins, []);
});
test("root workspace depends on the daemon package so pnpm exec resolves the od bin", () => {
const rootManifest = readPackageJson("package.json");
const daemonManifest = readPackageJson("apps/daemon/package.json");
assert.equal(dependencySpecifier(rootManifest, "@open-design/daemon"), "workspace:*");
assert.deepEqual((rootManifest as { bin?: unknown }).bin, {
od: "./apps/daemon/bin/od.mjs",
});
assert.deepEqual((daemonManifest as { bin?: unknown }).bin, {
od: "./bin/od.mjs",
});
assert.equal(existsSync(join(repoRoot, "apps/daemon/bin/od.mjs")), true);
});
test("postinstall builds workspace packages whose linkable bins delegate to dist", () => {
const rootManifest = readPackageJson("package.json");
const manifests = new Map(
workspacePackageDirectories().map((directory) => [
directory,
readJson(`${directory}/package.json`),
]),
);
const consumedWorkspacePackages = new Set<string>();
for (const name of workspaceDependencyNames(rootManifest, true)) {
consumedWorkspacePackages.add(name);
}
for (const manifest of manifests.values()) {
for (const name of workspaceDependencyNames(manifest)) {
consumedWorkspacePackages.add(name);
}
}
const missingBuildTargets = [...manifests.entries()]
.filter(([, manifest]) => consumedWorkspacePackages.has(packageName(manifest)))
.filter(([directory, manifest]) => distDelegatingBinTargets(directory, manifest).length > 0)
.map(([directory]) => directory)
.filter((directory) => !postinstallBuildTargets().has(directory));
assert.deepEqual(missingBuildTargets, []);
});