Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useT } from '../../../lib/i18n/I18nContext';
import type {
ProcessingTranscriptItem,
ToolFailureExplanation,
ToolTimelineEntry,
ToolTimelineEntryStatus,
} from '../../../store/chatRuntimeSlice';
Expand All @@ -12,6 +11,7 @@ import {
stripToolCallEnvelopes,
type ToolCategory,
} from '../../../utils/toolTimelineFormatting';
import { ToolFailureLines } from './ToolFailureLines';

/**
* The Hermes-style "View processing" body: the agent's narration and hidden
Expand Down Expand Up @@ -113,29 +113,6 @@ function ToolGroupBlock({ summary, entries }: { summary: string; entries: ToolTi
);
}

/**
* The failure classes the UI has localized copy for (#4254 / #4459), keyed by the
* camelCase form of the wire's PascalCase `class`. Any class not in this set
* falls back to the English `causePlain` / `nextAction` carried on the payload.
*/
const LOCALIZED_FAILURE_CLASSES: ReadonlySet<string> = new Set([
'missingPermission',
'missingApp',
'serviceUnavailable',
'badCredentials',
'blockedByPolicy',
'modelConnection',
'timeout',
'denied',
'approvalExpired',
'unknown',
]);

/** Lowercase the first character: `MissingPermission` → `missingPermission`. */
function toCamelClass(cls: string): string {
return cls.length > 0 ? cls[0].toLowerCase() + cls.slice(1) : cls;
}

/** One tool step: type icon + human sentence + contextual detail chip. */
function ToolRow({ entry }: { entry: ToolTimelineEntry }) {
const { title, detail } = formatTimelineEntry(entry);
Expand All @@ -159,36 +136,6 @@ function ToolRow({ entry }: { entry: ToolTimelineEntry }) {
);
}

/**
* The "why + what to do next" pair rendered under a failed tool row (#4254).
* Copy is resolved by failure class from i18n, falling back to the English
* `causePlain` / `nextAction` carried on the wire when the class is one the UI
* hasn't localized. The failure line is coral to match the error glyph.
*/
function ToolFailureLines({ failure }: { failure: ToolFailureExplanation }) {
const { t } = useT();
const camel = toCamelClass(failure.class);
const known = LOCALIZED_FAILURE_CLASSES.has(camel);
const cause = known
? t(`conversations.toolFailure.${camel}.cause`, failure.causePlain)
: failure.causePlain;
const next = known
? t(`conversations.toolFailure.${camel}.next`, failure.nextAction)
: failure.nextAction;
return (
<span
data-testid="processing-tool-failure"
className="mt-1 flex flex-col gap-0.5 text-[11px] leading-snug">
<span className="text-coral-600 dark:text-coral-300">
<span className="font-semibold">{t('conversations.toolFailure.whyLabel')}:</span> {cause}
</span>
<span className="text-content-muted">
<span className="font-semibold">{t('conversations.toolFailure.nextLabel')}:</span> {next}
</span>
</span>
);
}

/** Compact terminal status glyph for the group's "Done" line. */
function StatusGlyph({ status }: { status: ToolTimelineEntryStatus }) {
if (status === 'error') {
Expand Down
6 changes: 6 additions & 0 deletions app/src/pages/conversations/components/SubagentDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
import type { ThreadMessage } from '../../../types/thread';
import { stripToolCallEnvelopes } from '../../../utils/toolTimelineFormatting';
import { BubbleMarkdown } from './AgentMessageBubble';
import { ToolFailureLines } from './ToolFailureLines';

/**
* Rebuild a renderable transcript from a worker sub-thread's persisted
Expand Down Expand Up @@ -480,6 +481,11 @@ function ToolCallRow({ item }: { item: SubagentToolItem }) {
<span className="text-[10px] text-content-faint">{formatElapsed(item.elapsedMs)}</span>
) : null}
</button>
{item.status === 'error' && item.failure ? (
<div className="px-2.5 pb-1.5">
<ToolFailureLines failure={item.failure} />
</div>
) : null}
{expandable && expanded ? (
<div className="space-y-2 border-t border-line px-2.5 py-2">
{argsText != null ? (
Expand Down
56 changes: 56 additions & 0 deletions app/src/pages/conversations/components/ToolFailureLines.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useT } from '../../../lib/i18n/I18nContext';
import type { ToolFailureExplanation } from '../../../store/chatRuntimeSlice';

/**
* The failure classes the UI has localized copy for (#4254 / #4459), keyed by
* the camelCase form of the wire's PascalCase `class`. Any class not in this
* set falls back to the English `causePlain` / `nextAction` on the payload.
*/
const LOCALIZED_FAILURE_CLASSES: ReadonlySet<string> = new Set([
'missingPermission',
'missingApp',
'serviceUnavailable',
'badCredentials',
'blockedByPolicy',
'modelConnection',
'timeout',
'denied',
'approvalExpired',
'unknown',
]);

/** Lowercase the first character: `MissingPermission` → `missingPermission`. */
function toCamelClass(cls: string): string {
return cls.length > 0 ? cls[0].toLowerCase() + cls.slice(1) : cls;
}

/**
* The "why + what to do next" pair rendered under a failed tool row (#4254 /
* #4459). Copy resolves by failure class from i18n, falling back to the English
* `causePlain` / `nextAction` carried on the wire when the class is one the UI
* hasn't localized. Shared by the parent processing transcript and the
* sub-agent renderers so a failed child tool shows the same why/next copy.
*/
export function ToolFailureLines({ failure }: { failure: ToolFailureExplanation }) {
const { t } = useT();
const camel = toCamelClass(failure.class);
const known = LOCALIZED_FAILURE_CLASSES.has(camel);
const cause = known
? t(`conversations.toolFailure.${camel}.cause`, failure.causePlain)
: failure.causePlain;
const next = known
? t(`conversations.toolFailure.${camel}.next`, failure.nextAction)
: failure.nextAction;
return (
<span
data-testid="processing-tool-failure"
className="mt-1 flex flex-col gap-0.5 text-[11px] leading-snug">
<span className="text-coral-600 dark:text-coral-300">
<span className="font-semibold">{t('conversations.toolFailure.whyLabel')}:</span> {cause}
</span>
<span className="text-content-muted">
<span className="font-semibold">{t('conversations.toolFailure.nextLabel')}:</span> {next}
</span>
</span>
);
}
57 changes: 32 additions & 25 deletions app/src/pages/conversations/components/ToolTimelineBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import WorktreeActions from '../../../components/worktree/WorktreeActions';
import { useT } from '../../../lib/i18n/I18nContext';
import type {
SubagentActivity,
ToolFailureExplanation,
ToolTimelineEntry,
ToolTimelineEntryStatus,
} from '../../../store/chatRuntimeSlice';
Expand All @@ -14,6 +15,7 @@ import {
import { parseWorkerThreadRef } from '../utils/workerThreadRef';
import { BubbleMarkdown } from './AgentMessageBubble';
import { agentNameTone, AgentTimelineRail } from './AgentTimelineRail';
import { ToolFailureLines } from './ToolFailureLines';
import { WorkerThreadRefCard, type WorkerThreadStatus } from './WorkerThreadRefCard';

/**
Expand Down Expand Up @@ -96,39 +98,44 @@ function ToolCallRow({
displayName?: string;
/** Server-computed contextual detail (path / recipient / query). */
detail?: string;
/** Structured why/next explanation for a FAILED child tool call (#4459). */
failure?: ToolFailureExplanation;
};
}) {
const tone = toolCallTone(call.status);
return (
<div className="flex min-w-0 items-center gap-1.5" data-testid="subagent-tool-call">
<span aria-hidden className={`shrink-0 text-[11px] ${tone}`}>
</span>
<span className="shrink-0 text-[12px] whitespace-nowrap text-content-secondary">
{call.displayName ?? formatToolName(call.toolName)}
</span>
{/* The contextual arg (path / recipient / query) can be long, so it
<div className="min-w-0" data-testid="subagent-tool-call">
<div className="flex min-w-0 items-center gap-1.5">
<span aria-hidden className={`shrink-0 text-[11px] ${tone}`}>
</span>
<span className="shrink-0 text-[12px] whitespace-nowrap text-content-secondary">
{call.displayName ?? formatToolName(call.toolName)}
</span>
{/* The contextual arg (path / recipient / query) can be long, so it
truncates to a single line and absorbs the row's spare width — the
full value stays available on hover — instead of wrapping into a
multi-line box that knocks the name and status out of alignment. */}
{call.detail ? (
<span
title={call.detail}
className="min-w-0 truncate rounded bg-surface-subtle px-1 py-px font-mono text-[11px] text-content-muted">
{call.detail}
</span>
) : null}
{/* Status reads as a tinted "Done" / "Failed" / "Running" tag. */}
<span className="shrink-0">
<StatusTag status={call.status} />
</span>
{call.elapsedMs != null && call.status !== 'running' ? (
<span className="shrink-0 text-[11px] text-content-faint">
{call.elapsedMs >= 1000
? `${(call.elapsedMs / 1000).toFixed(1)}s`
: `${call.elapsedMs}ms`}
{call.detail ? (
<span
title={call.detail}
className="min-w-0 truncate rounded bg-surface-subtle px-1 py-px font-mono text-[11px] text-content-muted">
{call.detail}
</span>
) : null}
{/* Status reads as a tinted "Done" / "Failed" / "Running" tag. */}
<span className="shrink-0">
<StatusTag status={call.status} />
</span>
) : null}
{call.elapsedMs != null && call.status !== 'running' ? (
<span className="shrink-0 text-[11px] text-content-faint">
{call.elapsedMs >= 1000
? `${(call.elapsedMs / 1000).toFixed(1)}s`
: `${call.elapsedMs}ms`}
</span>
) : null}
</div>
{call.status === 'error' && call.failure ? <ToolFailureLines failure={call.failure} /> : null}
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,37 @@ describe('SubagentDrawer', () => {
expect(texts[1].textContent).toContain('The answer is');
});

it('renders the why/next explanation for a failed child tool call (#4459)', () => {
render(
<SubagentDrawer
subagent={activity({
transcript: [
{
kind: 'tool',
iteration: 1,
callId: 'cc-1',
toolName: 'shell',
status: 'error',
// A class not in LOCALIZED_FAILURE_CLASSES so the copy falls back
// to the verbatim causePlain/nextAction (i18n-independent assert).
failure: {
class: 'someUnclassifiedFailure',
category: 'user_declined',
recoverable: false,
causePlain: 'You declined this action.',
nextAction: 'Ask again if you change your mind.',
},
},
],
})}
onClose={() => {}}
/>
);
const failure = screen.getByTestId('processing-tool-failure');
expect(failure).toHaveTextContent('You declined this action.');
expect(failure).toHaveTextContent('Ask again if you change your mind.');
});

it('opens with the parent delegation prompt as a chat bubble', () => {
render(
<SubagentDrawer
Expand Down
26 changes: 22 additions & 4 deletions src/openhuman/tools/impl/filesystem/update_memory_md.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,28 @@ async fn acquire_cross_process_write_lock(workspace_dir: &Path) -> anyhow::Resul
if let Some(parent) = lock_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
// Reject a symlinked lock file: a project-controlled `.memory-write.lock`
// symlink (including a dangling one) could otherwise redirect this
// create/open/lock to a path OUTSIDE the already-containment-checked
// workspace, bypassing the symlink hardening applied to MEMORY.md /
// SKILL.md. If it exists it must be a regular file.
if let Ok(meta) = std::fs::symlink_metadata(&lock_path) {
if meta.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"workspace lock file {lock_path:?} is a symlink; refusing to follow it"
));
}
}
let mut opts = std::fs::OpenOptions::new();
opts.create(true).write(true).truncate(false);
#[cfg(unix)]
{
// O_NOFOLLOW closes the TOCTOU window: if a symlink is swapped in
// after the check above, the open fails (ELOOP) rather than follows.
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
Comment on lines +78 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed on Windows lock symlink races

This hardening only makes the final open no-follow under cfg(unix). In the supported Windows desktop build, the code still does symlink_metadata followed by a normal OpenOptions::open, so a workspace-controlled process can swap .memory-write.lock to a reparse-point symlink in that gap and redirect the advisory lock outside the containment-checked workspace. Please either reject this path on Windows or open with the Windows no-reparse/fail-if-symlink flags as well.

Useful? React with 👍 / 👎.

let file = opts
.open(&lock_path)
.map_err(|e| anyhow::anyhow!("open workspace lock file {lock_path:?}: {e}"))?;
file.lock_exclusive()
Expand Down
Loading