From 9410f52d77e07768fb1e3ad4d240c0627440ed5e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:29:52 +0000 Subject: [PATCH 01/26] feat(display-links): paint constructed http(s) URL cards via MCP Cursor-routed agents drop doubled letters when recalling URLs; store href bytes built outside the model (MCP display_links + CLI fallback) and render a tappable SessionChat card without a fake user turn. Fixes tiann/hapi#1516 Co-authored-by: Cursor --- AGENTS.md | 1 + cli/src/agent/hapiSessionEnv.ts | 2 +- cli/src/agent/runners/runAgentSession.test.ts | 4 +- cli/src/claude/utils/startHappyServer.test.ts | 37 +++- cli/src/claude/utils/startHappyServer.ts | 55 ++++- cli/src/claude/utils/systemPrompt.ts | 3 +- cli/src/codex/happyMcpStdioBridge.test.ts | 23 +- cli/src/codex/happyMcpStdioBridge.ts | 38 +++- .../codex/utils/buildHapiMcpBridge.test.ts | 10 +- cli/src/codex/utils/buildHapiMcpBridge.ts | 4 + cli/src/codex/utils/systemPrompt.ts | 3 +- cli/src/commands/displayLinks.test.ts | 41 ++++ cli/src/commands/displayLinks.ts | 203 ++++++++++++++++++ cli/src/commands/registry.ts | 4 +- cli/src/grok/utils/systemPrompt.ts | 3 +- cli/src/modules/common/displayImagePrompt.ts | 16 ++ cli/src/modules/common/hapiMcpBridgePrompt.ts | 5 +- cli/src/opencode/utils/systemPrompt.ts | 2 + cli/src/ui/doctorInlineMedia.ts | 6 +- scripts/tooling/hapi-display-links.mjs | 164 ++++++++++++++ shared/src/displayLinks.test.ts | 97 +++++++++ shared/src/displayLinks.ts | 130 +++++++++++ shared/src/index.ts | 1 + web/src/chat/normalizeAgent.test.ts | 38 ++++ web/src/chat/normalizeAgent.ts | 22 +- web/src/chat/reconcile.ts | 19 ++ web/src/chat/reducerTimeline.test.ts | 26 +++ web/src/chat/reducerTimeline.ts | 13 ++ web/src/chat/types.ts | 25 ++- .../ToolMessage.displayLinks.test.tsx | 44 ++++ .../AssistantChat/messages/ToolMessage.tsx | 79 ++++++- web/src/lib/assistant-runtime.ts | 22 ++ web/src/lib/sessionExport/markdown.ts | 2 + 33 files changed, 1115 insertions(+), 27 deletions(-) create mode 100644 cli/src/commands/displayLinks.test.ts create mode 100644 cli/src/commands/displayLinks.ts create mode 100644 scripts/tooling/hapi-display-links.mjs create mode 100644 shared/src/displayLinks.test.ts create mode 100644 shared/src/displayLinks.ts create mode 100644 web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 5d49368812..40e1851b47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ Start with the task's files; read only relevant sections of these references, no | Native API contract, chat conformance | [client contract](docs/api/client-contract/index.md), [iOS](ios/README.md), [Android](android/README.md) | | Encrypted native push relay | [relay/README.md](relay/README.md) | | User docs / marketing site | `docs/` (VitePress) / `website/` | +| Paint tappable URLs (Cursor) | MCP `display_links` / `hapi display-links` / `scripts/tooling/hapi-display-links.mjs` — construct hrefs by concatenation (`"tia"+"nn"`), never type landmine hosts in prose | ## Repository conventions diff --git a/cli/src/agent/hapiSessionEnv.ts b/cli/src/agent/hapiSessionEnv.ts index c46248d88e..b26d8f54ba 100644 --- a/cli/src/agent/hapiSessionEnv.ts +++ b/cli/src/agent/hapiSessionEnv.ts @@ -15,7 +15,7 @@ export const HAPI_SESSION_ID_ENV = 'HAPI_SESSION_ID'; * here covers claude / codex / copilot / cursor / gemini / opencode / kimi / grok / pi at * once, including future flavors, without touching each launcher. * - * Prefer the MCP `display_image` tool for inline media when it is available; + * Prefer the MCP `display_image` / `display_links` tools when they are available; * `HAPI_SESSION_ID` is the deterministic fallback for hub REST and shell tooling. * To discover / read / message another session, prefer MCP `list_peers` / * `inspect_peer` / `ping_peer` (or `hapi ping-peer --list` / `inspect-peer` / diff --git a/cli/src/agent/runners/runAgentSession.test.ts b/cli/src/agent/runners/runAgentSession.test.ts index fe1886578a..c7e7127c44 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -74,7 +74,7 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ harness.startHappyServerOptions = options return { url: 'http://127.0.0.1:1234', - toolNames: ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'], + toolNames: ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'], stop: harness.stopServer } }) @@ -167,7 +167,7 @@ describe('runAgentSession', () => { '--url', 'http://127.0.0.1:1234', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup' + 'change_title,display_image,display_video,display_media,display_links,list_peers,ping_peer,inspect_peer,skill_lookup' ]) expect(harness.newSessionOptions).toMatchObject({ cwd: '/tmp/project', diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index b507c1c4e0..7f0e901165 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -112,6 +112,7 @@ describe('startHappyServer skill_lookup', () => { 'display_image', 'display_video', 'display_media', + 'display_links', 'ping_peer', 'inspect_peer', 'list_peers' @@ -168,6 +169,37 @@ describe('startHappyServer skill_lookup', () => { })) }) + it('paints display_links via sendAgentMessage with concatenated href bytes', async () => { + const mcp = await connect(false) + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + + const result = await mcp.callTool({ + name: 'display_links', + arguments: { urls: [{ href, title: 'Issue 1516' }] } + }) as ToolResult + + expect(result.isError).toBe(false) + expect(result.content?.[0]?.text).toContain('Displayed 1 link') + expect(sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'display-links', + urls: [{ href: 'https://github.com/tiann/hapi/issues/1516', title: 'Issue 1516' }], + })) + const payload = sendAgentMessage.mock.calls[0]?.[0] as { urls: Array<{ href: string }> } + expect(payload.urls[0]?.href).toBe(href) + expect(payload.urls[0]?.href).not.toContain('tian/hapi') + }) + + it('rejects javascript hrefs without emitting an agent message', async () => { + const mcp = await connect(false) + const result = await mcp.callTool({ + name: 'display_links', + arguments: { urls: [{ href: 'javascript:alert(1)' }] } + }) as ToolResult + + expect(result.isError).toBe(true) + expect(sendAgentMessage).not.toHaveBeenCalled() + }) + it('does not expose change_title when native ACP titles are enabled', async () => { const sessionClient = { updateMetadata: vi.fn(), @@ -182,11 +214,12 @@ describe('startHappyServer skill_lookup', () => { await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url))) const tools = await mcp.listTools() - expect(server.toolNames).toEqual(['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']) + expect(server.toolNames).toEqual(['display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer']) expect(tools.tools.map((tool) => tool.name)).toEqual([ 'display_image', 'display_video', 'display_media', + 'display_links', 'ping_peer', 'inspect_peer', 'list_peers' @@ -202,6 +235,7 @@ describe('toClaudeAllowedHapiMcpTools', () => { 'display_image', 'display_video', 'display_media', + 'display_links', 'list_peers', 'ping_peer', 'inspect_peer', @@ -209,6 +243,7 @@ describe('toClaudeAllowedHapiMcpTools', () => { ])).toEqual([ 'mcp__hapi__change_title', 'mcp__hapi__display_image', + 'mcp__hapi__display_links', 'mcp__hapi__list_peers', 'mcp__hapi__skill_lookup' ]) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index f14af6c024..93e648b8f3 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -19,7 +19,8 @@ import { registerGeneratedImage, } from "@/modules/common/generatedImages"; import type { InlineMediaSource } from "@/modules/common/inlineMediaSource"; -import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from "@/modules/common/displayImagePrompt"; +import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_LINKS_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from "@/modules/common/displayImagePrompt"; +import { buildDisplayLinksPayload, parseDisplayLinksInput } from "@hapi/protocol"; import { resolveSkill } from "@/modules/common/skills"; import { INSPECT_PEER_TOOL_DESCRIPTION, @@ -108,6 +109,16 @@ function createHapiMcpServer( title: z.string().trim().min(1).max(255).optional().describe('Optional display title or filename'), }); + const displayLinksInputSchema: z.ZodTypeAny = z.object({ + urls: z.array(z.union([ + z.object({ + href: z.string().describe('http(s) URL to paint. Construct by concatenation for landmine strings (tia+nn), never copy from model prose.'), + title: z.string().trim().min(1).max(255).optional().describe('Optional link label shown on the card'), + }), + z.string(), + ])).min(1).max(20).describe('One or more http(s) URLs to paint as tappable cards'), + }); + const pingPeerInputSchema: z.ZodTypeAny = z.object({ sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION), message: z.string().min(1).describe('Message text to deliver to the target session'), @@ -291,6 +302,44 @@ function createHapiMcpServer( } }); + mcp.registerTool('display_links', { + description: `Paint clickable http(s) URL cards into the current HAPI chat without a fake user turn. ${DISPLAY_LINKS_PROMPT_CURSOR}`, + title: 'Display Links', + inputSchema: displayLinksInputSchema, + }, async (args: { urls: Array<{ href: string; title?: string } | string> }) => { + logger.debug('[hapiMCP] Display links:', args.urls); + + try { + const urls = parseDisplayLinksInput(args.urls); + client.sendAgentMessage(buildDisplayLinksPayload({ + urls, + id: randomUUID(), + })); + + return { + content: [ + { + type: 'text' as const, + text: `Displayed ${urls.length} link${urls.length === 1 ? '' : 's'}`, + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.debug('[hapiMCP] Failed to display links:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to display links: ${message}`, + }, + ], + isError: true, + }; + } + }); + mcp.registerTool('ping_peer', { description: PING_PEER_TOOL_DESCRIPTION, title: 'Ping Peer Session', @@ -534,8 +583,8 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH })); const toolNames = enableChangeTitle - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'] - : ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; + ? ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer'] + : ['display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer']; if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 3174ac6edd..8833a26911 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,7 +1,7 @@ import { trimIdent } from "@/utils/trimIdent"; import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCitation"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; -import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; +import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_LINKS_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; /** @@ -12,6 +12,7 @@ const BASE_SYSTEM_PROMPT = (() => trimIdent(` ${DISPLAY_IMAGE_PROMPT_CLAUDE} ${DISPLAY_VIDEO_PROMPT_CLAUDE} ${DISPLAY_MEDIA_PROMPT_CLAUDE} + ${DISPLAY_LINKS_PROMPT_CLAUDE} ${buildSessionCitationSteerInstruction({ inspectTool: 'mcp__hapi__inspect_peer', pingTool: 'mcp__hapi__ping_peer', diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index c940d0bc50..08c05906c2 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -70,7 +70,7 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { '--url', 'http://127.0.0.1:43006', '--tools', - 'change_title,display_image,display_video,display_media,skill_lookup' + 'change_title,display_image,display_video,display_media,display_links,skill_lookup' ]) expect([...harness.tools.keys()]).toEqual([ @@ -78,6 +78,7 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { 'display_image', 'display_video', 'display_media', + 'display_links', 'skill_lookup' ]) @@ -104,6 +105,26 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { expect([...harness.tools.keys()]).toEqual(['change_title', 'display_image', 'display_video']) }) + it('forwards display_links arguments unchanged', async () => { + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + 'display_links' + ]) + + const handler = harness.tools.get('display_links') + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + await expect(handler?.({ urls: [{ href, title: 'Issue 1516' }] })).resolves.toEqual({ + content: [{ type: 'text', text: 'forwarded' }], + isError: false + }) + expect(harness.callTool).toHaveBeenCalledWith({ + name: 'display_links', + arguments: { urls: [{ href, title: 'Issue 1516' }] } + }) + }) + it('forwards display_media arguments unchanged', async () => { await runHappyMcpStdioBridge([ '--url', diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index be0d03d0bb..a39b4034c2 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -1,7 +1,7 @@ /** * HAPI MCP STDIO Bridge * - * Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `list_peers`, `ping_peer`, and `inspect_peer`. + * Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `display_links`, `list_peers`, `ping_peer`, and `inspect_peer`. * On invocation it forwards the tool call to an existing HAPI HTTP MCP server * using the StreamableHTTPClientTransport. * @@ -16,14 +16,14 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { z } from 'zod'; -import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from '@/modules/common/displayImagePrompt'; +import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_LINKS_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from '@/modules/common/displayImagePrompt'; import { INSPECT_PEER_TOOL_DESCRIPTION, PING_PEER_TOOL_DESCRIPTION, SESSION_ID_PREFIX_PARAM_DESCRIPTION, } from '@hapi/protocol/sessionCitation'; -const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; +const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer']; function parseArgs(argv: string[]): { url: string | null; toolNames: Set } { let url: string | null = null; @@ -201,6 +201,38 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { message: z.string().min(1).describe('Message text to deliver to the target session'), }); + const displayLinksInputSchema: z.ZodTypeAny = z.object({ + urls: z.array(z.union([ + z.object({ + href: z.string().describe('http(s) URL to paint. Construct by concatenation for landmine strings (tia+nn), never copy from model prose.'), + title: z.string().trim().min(1).max(255).optional().describe('Optional link label shown on the card'), + }), + z.string(), + ])).min(1).max(20).describe('One or more http(s) URLs to paint as tappable cards'), + }); + + if (toolNames.has('display_links')) { + server.registerTool( + 'display_links', + { + description: `Paint clickable http(s) URL cards into the current HAPI chat. ${DISPLAY_LINKS_PROMPT_CURSOR}`, + title: 'Display Links', + inputSchema: displayLinksInputSchema, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + return await client.callTool({ name: 'display_links', arguments: args }) as any; + } catch (error) { + return { + content: [{ type: 'text' as const, text: `Failed to display links: ${error instanceof Error ? error.message : String(error)}` }], + isError: true, + }; + } + } + ); + } + if (toolNames.has('ping_peer')) { server.registerTool( 'ping_peer', diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index 8af0d57768..85143d80e6 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -14,8 +14,8 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ return { url: 'http://127.0.0.1:43006/', toolNames: options.skillLookup - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'] - : ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'], + ? ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'] + : ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer'], stop: vi.fn() } }) @@ -71,13 +71,14 @@ describe('buildHapiMcpBridge skill lookup config', () => { '--url', 'http://127.0.0.1:43006/', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup' + 'change_title,display_image,display_video,display_media,display_links,list_peers,ping_peer,inspect_peer,skill_lookup' ]) expect(bridge.mcpServers.hapi.tools).toEqual({ change_title: { approval_mode: 'approve' }, display_image: { approval_mode: 'prompt' }, display_video: { approval_mode: 'prompt' }, display_media: { approval_mode: 'prompt' }, + display_links: { approval_mode: 'approve' }, list_peers: { approval_mode: 'approve' }, skill_lookup: { approval_mode: 'approve' } }) @@ -86,12 +87,13 @@ describe('buildHapiMcpBridge skill lookup config', () => { it('does not expose skill_lookup for native-skill bridge callers', async () => { const bridge = await buildHapiMcpBridge(createClient()) - expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer') + expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,display_video,display_media,display_links,list_peers,ping_peer,inspect_peer') expect(bridge.mcpServers.hapi.tools).toEqual({ change_title: { approval_mode: 'approve' }, display_image: { approval_mode: 'prompt' }, display_video: { approval_mode: 'prompt' }, display_media: { approval_mode: 'prompt' }, + display_links: { approval_mode: 'approve' }, list_peers: { approval_mode: 'approve' } }) }) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 0d6ae4856f..a1ac26d207 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -100,6 +100,10 @@ export async function buildHapiMcpBridge( }, display_media: { approval_mode: 'prompt' + }, + // No local-file read — auto-approve so models actually use it instead of typing URLs. + display_links: { + approval_mode: 'approve' } }; if (options.enableChangeTitle !== false) { diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index bd85efc36c..de60ea5155 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -7,7 +7,7 @@ import { trimIdent } from '@/utils/trimIdent'; import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; -import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; +import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_LINKS_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** @@ -24,6 +24,7 @@ export const TITLE_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_CODEX} ${DISPLAY_VIDEO_PROMPT_CODEX} ${DISPLAY_MEDIA_PROMPT_CODEX} + ${DISPLAY_LINKS_PROMPT_CODEX} ${buildSessionCitationSteerInstruction({ inspectTool: 'functions.hapi__inspect_peer', pingTool: 'functions.hapi__ping_peer', diff --git a/cli/src/commands/displayLinks.test.ts b/cli/src/commands/displayLinks.test.ts new file mode 100644 index 0000000000..30df05b6d4 --- /dev/null +++ b/cli/src/commands/displayLinks.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { parseDisplayLinksArgs } from './displayLinks' + +describe('parseDisplayLinksArgs', () => { + it('treats a leading http(s) href as self-target', () => { + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + expect(parseDisplayLinksArgs([href, 'Issue 1516'])).toEqual({ + help: false, + sessionArg: null, + href, + title: 'Issue 1516', + }) + }) + + it('parses session prefix + href + title', () => { + expect(parseDisplayLinksArgs(['abc12345', 'https://example.com', 'Example'])).toEqual({ + help: false, + sessionArg: 'abc12345', + href: 'https://example.com', + title: 'Example', + }) + }) + + it('parses self token', () => { + expect(parseDisplayLinksArgs(['self', 'https://example.com'])).toEqual({ + help: false, + sessionArg: 'self', + href: 'https://example.com', + title: undefined, + }) + }) + + it('parses --help', () => { + expect(parseDisplayLinksArgs(['--help']).help).toBe(true) + }) + + it('throws when href is missing', () => { + expect(() => parseDisplayLinksArgs([])).toThrow(/missing href/) + expect(() => parseDisplayLinksArgs(['self'])).toThrow(/missing href/) + }) +}) diff --git a/cli/src/commands/displayLinks.ts b/cli/src/commands/displayLinks.ts new file mode 100644 index 0000000000..4c2eb107db --- /dev/null +++ b/cli/src/commands/displayLinks.ts @@ -0,0 +1,203 @@ +import chalk from 'chalk' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { parseDisplayLinksInput } from '@hapi/protocol' +import { getAuthToken } from '@/api/auth' +import { configuration } from '@/configuration' +import { initializeToken } from '@/ui/tokenInit' +import type { CommandDefinition } from './types' + +const SELF_TOKENS = new Set(['self', '@self', '@me', 'current', '-']) + +type ParsedDisplayLinksArgs = { + help: boolean + sessionArg: string | null + href: string + title?: string +} + +export function parseDisplayLinksArgs(args: string[]): ParsedDisplayLinksArgs { + if (args.includes('--help') || args.includes('-h')) { + return { help: true, sessionArg: null, href: '' } + } + if (args.length === 0) { + throw new Error('missing href; usage: hapi display-links [|self] [title]') + } + + const firstLooksLikeHref = /^https?:\/\//i.test(args[0] ?? '') + if (firstLooksLikeHref) { + return { + help: false, + sessionArg: null, + href: args[0]!, + title: args[1], + } + } + + if (args.length < 2) { + throw new Error('missing href; usage: hapi display-links [|self] [title]') + } + + return { + help: false, + sessionArg: args[0] ?? null, + href: args[1]!, + title: args[2], + } +} + +function showHelp(): void { + console.log(` +${chalk.bold('hapi display-links')} - Paint tappable http(s) URL cards into a HAPI session + +${chalk.bold('Usage:')} + hapi display-links [title] + hapi display-links self [title] + hapi display-links [title] + +${chalk.bold('Notes:')} + Uses the session MCP bridge (same path as display_image). Does not create a user turn. + Construct landmine hosts by concatenation in the calling script ("tia"+"nn"), never from model prose. + http/https only. javascript/data/vbscript/file are rejected. + +${chalk.bold('Env:')} + HAPI_SESSION_ID (self-target), HAPI_API_URL / CLI_API_TOKEN +`) +} + +function sessionMatchesPrefix(session: { id?: string; metadata?: Record }, prefix: string): boolean { + if (typeof session.id === 'string' && session.id.startsWith(prefix)) return true + const meta = session.metadata ?? {} + const agentIds = [ + meta.agentSessionId, + meta.cursorSessionId, + meta.codexSessionId, + meta.claudeSessionId, + meta.geminiSessionId, + meta.opencodeSessionId, + meta.kimiSessionId, + ] + return agentIds.some((id) => typeof id === 'string' && id.startsWith(prefix)) +} + +async function authHeaders(): Promise> { + const accessToken = getAuthToken() + const authRes = await fetch(`${configuration.apiUrl}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessToken }), + }) + if (!authRes.ok) { + throw new Error(`auth failed (${authRes.status})`) + } + const body = await authRes.json() as { token?: string } + if (!body.token) { + throw new Error('auth failed (missing JWT)') + } + return { Authorization: `Bearer ${body.token}` } +} + +async function fetchSessionDetail(sessionId: string, headers: Record): Promise | null> { + const res = await fetch(`${configuration.apiUrl}/api/sessions/${encodeURIComponent(sessionId)}`, { headers }) + if (!res.ok) return null + const body = await res.json() as { session?: Record } & Record + return body.session ?? body +} + +async function resolveSession( + sessionArg: string | null, + headers: Record +): Promise> { + const wantsSelf = !sessionArg || SELF_TOKENS.has(sessionArg) + const hapiSessionId = process.env.HAPI_SESSION_ID?.trim() + + if (wantsSelf) { + if (!hapiSessionId) { + throw new Error( + 'cannot self-resolve session: $HAPI_SESSION_ID is not set. ' + + 'Pass an explicit , or run inside a HAPI-wrapped agent session.' + ) + } + const session = await fetchSessionDetail(hapiSessionId, headers) + if (!session) { + throw new Error(`GET /api/sessions/${hapiSessionId} failed (HAPI_SESSION_ID set but hub has no such row)`) + } + return session + } + + const looksFull = /^[0-9a-f-]{36}$/i.test(sessionArg) + if (looksFull) { + const session = await fetchSessionDetail(sessionArg, headers) + if (session) return session + } + + const listRes = await fetch(`${configuration.apiUrl}/api/sessions?limit=500`, { headers }) + const listBody = await listRes.json() as { sessions?: Array> } | Array> + const sessions = Array.isArray(listBody) ? listBody : (listBody.sessions ?? []) + const matches = sessions.filter((candidate) => sessionMatchesPrefix(candidate, sessionArg)) + if (matches.length !== 1) { + throw new Error( + matches.length === 0 + ? `no session for prefix ${sessionArg}` + : `ambiguous session prefix ${sessionArg} (${matches.length} matches)` + ) + } + const listed = matches[0]! + const id = typeof listed.id === 'string' ? listed.id : sessionArg + return await fetchSessionDetail(id, headers) ?? listed +} + +export async function handleDisplayLinksCommand(args: string[]): Promise { + const parsed = parseDisplayLinksArgs(args) + if (parsed.help) { + showHelp() + return + } + + const urls = parseDisplayLinksInput( + parsed.title ? [{ href: parsed.href, title: parsed.title }] : [{ href: parsed.href }] + ) + + await initializeToken() + const headers = await authHeaders() + const session = await resolveSession(parsed.sessionArg, headers) + const metadata = session.metadata && typeof session.metadata === 'object' + ? session.metadata as Record + : {} + const mcpUrl = typeof metadata.hapiMcpUrl === 'string' ? metadata.hapiMcpUrl : null + if (!mcpUrl) { + throw new Error('session has no hapiMcpUrl metadata (restart session CLI after MCP server start)') + } + + const sessionId = typeof session.id === 'string' ? session.id : 'unknown' + console.error(`hapi display-links: session=${sessionId} mcp=${mcpUrl}`) + + const client = new Client({ name: 'hapi-display-links', version: '1.0.0' }, { capabilities: {} }) + const transport = new StreamableHTTPClientTransport(new URL(mcpUrl)) + await client.connect(transport) + try { + const result = await client.callTool({ + name: 'display_links', + arguments: { urls }, + }) + console.log(JSON.stringify(result, null, 2)) + } finally { + await client.close() + } +} + +export const displayLinksCommand: CommandDefinition = { + name: 'display-links', + requiresRuntimeAssets: false, + run: async ({ commandArgs }) => { + try { + await handleDisplayLinksCommand(commandArgs) + } catch (error) { + console.error( + chalk.red('hapi display-links:'), + error instanceof Error ? error.message : 'Unknown error' + ) + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 2bf8c598fb..1b854aac97 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -21,6 +21,7 @@ import { notifyCommand } from './notify' import { hubCommand } from './hub' import { pingPeerCommand } from './pingPeer' import { inspectPeerCommand } from './inspectPeer' +import { displayLinksCommand } from './displayLinks' import type { CommandContext, CommandDefinition } from './types' // Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on @@ -62,7 +63,8 @@ const COMMANDS: CommandDefinition[] = [ runnerCommand, notifyCommand, pingPeerCommand, - inspectPeerCommand + inspectPeerCommand, + displayLinksCommand ] const commandMap = new Map() diff --git a/cli/src/grok/utils/systemPrompt.ts b/cli/src/grok/utils/systemPrompt.ts index 695bc9ea42..044b21051e 100644 --- a/cli/src/grok/utils/systemPrompt.ts +++ b/cli/src/grok/utils/systemPrompt.ts @@ -1,8 +1,9 @@ +import { DISPLAY_LINKS_PROMPT_HAPI_MCP } from '@/modules/common/displayImagePrompt' import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction' import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction' export const GROK_TITLE_INSTRUCTION = - `Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.\n${SKILL_LOOKUP_INSTRUCTION}` + `Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.\n${DISPLAY_LINKS_PROMPT_HAPI_MCP}\n${SKILL_LOOKUP_INSTRUCTION}` export function getGrokTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { return withSessionSummaryInstruction(GROK_TITLE_INSTRUCTION, env) diff --git a/cli/src/modules/common/displayImagePrompt.ts b/cli/src/modules/common/displayImagePrompt.ts index 48dfbf09e5..e91ad42512 100644 --- a/cli/src/modules/common/displayImagePrompt.ts +++ b/cli/src/modules/common/displayImagePrompt.ts @@ -55,3 +55,19 @@ export const DISPLAY_MEDIA_PROMPT_HAPI_MCP = trimIdent(` export const DISPLAY_MEDIA_PROMPT_CURSOR = trimIdent(` When you create or find a local audio file or other non-image file that the user should receive, call the tool "display_media" with the absolute filesystem path so HAPI can show a player or download card. `); + +export const DISPLAY_LINKS_PROMPT_CLAUDE = trimIdent(` + When the operator needs a tappable URL in HAPI chat, call "mcp__hapi__display_links" with { urls: [{ href, title? }] }. Do not type URLs in assistant prose — models drop doubled letters when recalling hosts (tiann→tian) and operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. +`); + +export const DISPLAY_LINKS_PROMPT_CODEX = trimIdent(` + When the operator needs a tappable URL in HAPI chat, call functions.hapi__display_links with { urls: [{ href, title? }] }. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_links, mcp__hapi__display_links, or hapi_display_links. Do not type URLs in assistant prose — models drop doubled letters when recalling hosts (tiann→tian) and operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. +`); + +export const DISPLAY_LINKS_PROMPT_HAPI_MCP = trimIdent(` + When the operator needs a tappable URL in HAPI chat, call the tool "hapi_display_links" with { urls: [{ href, title? }] }. If that exact tool name is unavailable, use an equivalent alias such as display_links or mcp__hapi__display_links. Do not type URLs in assistant prose — models drop doubled letters when recalling hosts (tiann→tian) and operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. +`); + +export const DISPLAY_LINKS_PROMPT_CURSOR = trimIdent(` + When the operator needs a tappable URL in this HAPI chat, call the tool "display_links" with { urls: [{ href, title? }] }. Do not type URLs in assistant prose — Cursor-routed models drop doubled letters when recalling hosts (tiann→tian, MagicDNS labels) and headset operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. +`); diff --git a/cli/src/modules/common/hapiMcpBridgePrompt.ts b/cli/src/modules/common/hapiMcpBridgePrompt.ts index 3017c362c2..f6fb4d273d 100644 --- a/cli/src/modules/common/hapiMcpBridgePrompt.ts +++ b/cli/src/modules/common/hapiMcpBridgePrompt.ts @@ -1,14 +1,16 @@ import { trimIdent } from '@/utils/trimIdent'; import { DISPLAY_IMAGE_PROMPT_HAPI_MCP, + DISPLAY_LINKS_PROMPT_HAPI_MCP, DISPLAY_MEDIA_PROMPT_HAPI_MCP, DISPLAY_VIDEO_PROMPT_HAPI_MCP, } from './displayImagePrompt'; /** Shell fallback for `hapi doctor inline-media` only — not injected into agent prompts. */ export const INLINE_MEDIA_SHELL_FALLBACK = trimIdent(` - If display_image / display_video / display_media tools are not in your tool list, inline media via shell from the HAPI repo (needs bun + @modelcontextprotocol/sdk): + If display_image / display_video / display_media / display_links tools are not in your tool list, inline media via shell from the HAPI repo (needs bun + @modelcontextprotocol/sdk): cd && bun scripts/tooling/hapi-display-image.mjs "title" + For clickable URLs (never type landmine hosts in prose): bun scripts/tooling/hapi-display-links.mjs "title" Use the HAPI session uuid prefix from the web URL /sessions/ (first 8 chars), not cursorSessionId or other agent-native ids. Run hapi doctor inline-media to list active bridges and copy the exact command. `); @@ -27,4 +29,5 @@ export const HAPI_MCP_BRIDGE_PROMPT = trimIdent(` ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} ${DISPLAY_MEDIA_PROMPT_HAPI_MCP} + ${DISPLAY_LINKS_PROMPT_HAPI_MCP} `); diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index b1838d33e7..eb8c9d3ce9 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -10,6 +10,7 @@ import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCita import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; import { DISPLAY_IMAGE_PROMPT_HAPI_MCP, + DISPLAY_LINKS_PROMPT_HAPI_MCP, DISPLAY_MEDIA_PROMPT_HAPI_MCP, DISPLAY_VIDEO_PROMPT_HAPI_MCP, } from '@/modules/common/displayImagePrompt'; @@ -41,6 +42,7 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} ${DISPLAY_MEDIA_PROMPT_HAPI_MCP} + ${DISPLAY_LINKS_PROMPT_HAPI_MCP} ${buildSessionCitationSteerInstruction({ inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', diff --git a/cli/src/ui/doctorInlineMedia.ts b/cli/src/ui/doctorInlineMedia.ts index 5f3cb82a07..f9dea4b25e 100644 --- a/cli/src/ui/doctorInlineMedia.ts +++ b/cli/src/ui/doctorInlineMedia.ts @@ -1,5 +1,5 @@ /** - * Inline media bridge diagnostics (display_image / display_video / display_media + helper script). + * Inline media bridge diagnostics (display_image / display_video / display_media / display_links + helper script). */ import chalk from 'chalk' @@ -224,7 +224,7 @@ export async function runDoctorInlineMedia(): Promise { if (cursorSessions.length > 0) { console.log(chalk.bold('\nCursor ACP')) console.log(chalk.gray(' Cursor ignores session/new mcpServers. Remote sessions use ~/.cursor/mcp.json + `agent mcp enable hapi-`.')) - console.log(chalk.gray(' Tool names are bare: display_image, display_video, display_media, change_title (not hapi_display_image).')) + console.log(chalk.gray(' Tool names are bare: display_image, display_video, display_media, display_links, change_title (not hapi_display_image).')) for (const session of cursorSessions) { const serverId = cursorHapiMcpServerId(session.id) console.log(chalk.gray(` Verify (${session.prefix}): agent mcp list-tools ${serverId}`)) @@ -232,7 +232,7 @@ export async function runDoctorInlineMedia(): Promise { } console.log(chalk.bold('\nAgent inline path')) - console.log(chalk.gray(' 1. MCP tool display_image / display_video / display_media in the running session (ACP flavors via hapi bridge)')) + console.log(chalk.gray(' 1. MCP tool display_image / display_video / display_media / display_links in the running session (ACP flavors via hapi bridge)')) if (shellFallbackAvailable) { console.log(chalk.gray(' 2. Shell fallback (HAPI session id prefix, not cursorSessionId):')) if (withBridge.length > 0) { diff --git a/scripts/tooling/hapi-display-links.mjs b/scripts/tooling/hapi-display-links.mjs new file mode 100644 index 0000000000..09334e672b --- /dev/null +++ b/scripts/tooling/hapi-display-links.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env bun +/** + * Paint tappable http(s) URL cards into a HAPI session via display_links MCP. + * + * Uses session.metadata.hapiMcpUrl (published at MCP server start) so we hit the MCP + * endpoint, not the session hook server on another loopback port in the same process. + * + * Usage: + * # inside a wrapped session (self-targets via $HAPI_SESSION_ID — no list): + * bun scripts/tooling/hapi-display-links.mjs [title] + * # explicit self: + * bun scripts/tooling/hapi-display-links.mjs self [title] + * # explicit other session: + * bun scripts/tooling/hapi-display-links.mjs [title] + * + * Construct landmine hosts by concatenation in the calling script ("tia"+"nn"), + * never copy a URL from model prose. http/https only. + */ + +import { readFileSync } from 'node:fs' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +const HAPI_HOST = process.env.HAPI_HOST ?? process.env.HAPI_API_URL ?? 'http://localhost:3006' +const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json` + +const SELF_TOKENS = new Set(['self', '@self', '@me', 'current', '-']) + +function sessionMatchesPrefix(session, prefix) { + if (typeof session.id === 'string' && session.id.startsWith(prefix)) { + return true + } + const meta = session.metadata ?? {} + const agentIds = [ + meta.agentSessionId, + meta.cursorSessionId, + meta.codexSessionId, + meta.claudeSessionId, + meta.geminiSessionId, + meta.opencodeSessionId, + meta.kimiSessionId, + ] + return agentIds.some((id) => typeof id === 'string' && id.startsWith(prefix)) +} + +function looksLikeHref(value) { + return typeof value === 'string' && /^https?:\/\//i.test(value) +} + +// Arg shapes (backward compatible with display-image style): +// [title] → self-target current session +// [title] → self-target, explicit +// [title] → explicit session +const args = process.argv.slice(2) +let sessionArg +let href +let title +if (args.length > 0 && looksLikeHref(args[0]) && !SELF_TOKENS.has(args[0])) { + sessionArg = null + href = args[0] + title = args[1] +} else { + sessionArg = args[0] + href = args[1] + title = args[2] +} + +if (!href) { + console.error('usage: hapi-display-links.mjs [|self] [title]') + console.error(' or: HAPI_SESSION_ID= hapi-display-links.mjs [title]') + process.exit(2) +} + +const token = process.env.CLI_API_TOKEN ?? JSON.parse(readFileSync(SETTINGS, 'utf8')).cliApiToken +if (!token) { + console.error('missing CLI_API_TOKEN env and no cliApiToken in settings') + process.exit(2) +} +const authRes = await fetch(`${HAPI_HOST}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessToken: token }), +}) +if (!authRes.ok) { + console.error('auth failed', authRes.status) + process.exit(3) +} +const { token: jwt } = await authRes.json() +const authHeaders = { Authorization: `Bearer ${jwt}` } + +async function fetchSessionDetail(sessionId) { + const detailRes = await fetch(`${HAPI_HOST}/api/sessions/${encodeURIComponent(sessionId)}`, { + headers: authHeaders, + }) + if (!detailRes.ok) { + return null + } + const detailBody = await detailRes.json() + return detailBody.session ?? detailBody +} + +async function listSessions() { + const sessionsRes = await fetch(`${HAPI_HOST}/api/sessions?limit=500`, { + headers: authHeaders, + }) + const sessionsBody = await sessionsRes.json() + return sessionsBody.sessions ?? sessionsBody +} + +let session +const wantsSelf = !sessionArg || SELF_TOKENS.has(sessionArg) +const hapiSessionId = process.env.HAPI_SESSION_ID?.trim() + +if (wantsSelf) { + if (!hapiSessionId) { + console.error( + 'cannot self-resolve session: $HAPI_SESSION_ID is not set. ' + + 'Pass an explicit , or run inside a HAPI-wrapped agent session.', + ) + process.exit(4) + } + session = await fetchSessionDetail(hapiSessionId) + if (!session) { + console.error(`GET /api/sessions/${hapiSessionId} failed (HAPI_SESSION_ID set but hub has no such row)`) + process.exit(4) + } +} else { + const looksFull = /^[0-9a-f-]{36}$/i.test(sessionArg) + if (looksFull) { + session = await fetchSessionDetail(sessionArg) + } + if (!session) { + const sessions = await listSessions() + const matches = sessions.filter((candidate) => sessionMatchesPrefix(candidate, sessionArg)) + if (matches.length !== 1) { + console.error( + matches.length === 0 + ? `no session for prefix ${sessionArg} (use HAPI session id from /sessions/, not cursorSessionId alone)` + : `ambiguous session prefix ${sessionArg} (${matches.length} matches); use a full HAPI session id`, + ) + process.exit(4) + } + const listed = matches[0] + session = await fetchSessionDetail(listed.id) ?? listed + } +} + +const mcpUrl = session.metadata?.hapiMcpUrl +if (!mcpUrl) { + console.error('session has no hapiMcpUrl metadata (restart session CLI after MCP server start)') + process.exit(5) +} + +console.error(`hapi-display-links: session=${session.id} mcp=${mcpUrl}`) + +const client = new Client({ name: 'hapi-display-links', version: '1.0.0' }, { capabilities: {} }) +const transport = new StreamableHTTPClientTransport(new URL(mcpUrl)) +await client.connect(transport) +const result = await client.callTool({ + name: 'display_links', + arguments: { urls: title ? [{ href, title }] : [{ href }] }, +}) +await client.close() +console.log(JSON.stringify(result, null, 2)) diff --git a/shared/src/displayLinks.test.ts b/shared/src/displayLinks.test.ts new file mode 100644 index 0000000000..0bd5ab62fe --- /dev/null +++ b/shared/src/displayLinks.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'bun:test' +import { + buildDisplayLinksPayload, + isDisplayableHttpHref, + parseDisplayLinksInput, + safeParseDisplayLinksInput, +} from './displayLinks' + +describe('isDisplayableHttpHref', () => { + it('accepts a landmine URL built by concatenation without rewriting bytes', () => { + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + expect(href).toBe('https://github.com/tiann/hapi/issues/1516') + expect(isDisplayableHttpHref(href)).toBe(true) + }) + + it('accepts http and https', () => { + expect(isDisplayableHttpHref('https://hapi-gc-oos.forest-adder.ts.net/sessions/abc')).toBe(true) + expect(isDisplayableHttpHref('http://example.com/path')).toBe(true) + }) + + it.each([ + 'javascript:alert(1)', + 'data:text/html,xss', + 'vbscript:msgbox(1)', + 'file:///tmp/secret', + 'mailto:ops@example.com', + '/relative/path', + 'not a url', + '', + ])('rejects %s', (href) => { + expect(isDisplayableHttpHref(href)).toBe(false) + }) + + it('rejects encoded javascript bypasses', () => { + expect(isDisplayableHttpHref('javascript%3Aalert(1)')).toBe(false) + expect(isDisplayableHttpHref('jav%61script:alert(1)')).toBe(false) + }) +}) + +describe('parseDisplayLinksInput', () => { + it('round-trips a concatenated landmine href as stored bytes', () => { + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + const urls = parseDisplayLinksInput([{ href, title: 'Issue 1516' }]) + expect(urls).toEqual([{ href: 'https://github.com/tiann/hapi/issues/1516', title: 'Issue 1516' }]) + expect(urls[0]?.href).toBe(href) + }) + + it('accepts a bare href string in the urls array', () => { + const href = 'https://example.com/a' + expect(parseDisplayLinksInput([href])).toEqual([{ href }]) + }) + + it('omits empty titles rather than storing blanks', () => { + expect(parseDisplayLinksInput([{ href: 'https://example.com', title: ' ' }])).toEqual([ + { href: 'https://example.com' }, + ]) + }) + + it('throws when urls is missing or empty', () => { + expect(() => parseDisplayLinksInput(undefined)).toThrow(/urls/) + expect(() => parseDisplayLinksInput([])).toThrow(/at least one/) + }) + + it('throws on deny-scheme hrefs instead of rewriting them', () => { + expect(() => parseDisplayLinksInput([{ href: 'javascript:alert(1)' }])).toThrow(/rejected/) + }) +}) + +describe('safeParseDisplayLinksInput', () => { + it('drops invalid entries instead of throwing (untrusted stored payloads)', () => { + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + expect(safeParseDisplayLinksInput([ + { href: 'javascript:alert(1)' }, + { href }, + { href: 'not-a-url' }, + ])).toEqual([{ href }]) + }) + + it('returns [] for non-arrays', () => { + expect(safeParseDisplayLinksInput(null)).toEqual([]) + expect(safeParseDisplayLinksInput({ href: 'https://example.com' })).toEqual([]) + }) +}) + +describe('buildDisplayLinksPayload', () => { + it('stores caller href bytes on the wire payload', () => { + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + const payload = buildDisplayLinksPayload({ + urls: [{ href, title: 'display_links' }], + id: 'link-1', + }) + expect(payload.type).toBe('display-links') + expect(payload.urls[0]?.href).toBe(href) + expect(JSON.stringify(payload)).toContain('tiann/hapi') + expect(JSON.stringify(payload)).not.toContain('tian/hapi') + }) +}) diff --git a/shared/src/displayLinks.ts b/shared/src/displayLinks.ts new file mode 100644 index 0000000000..c923a77ee6 --- /dev/null +++ b/shared/src/displayLinks.ts @@ -0,0 +1,130 @@ +/** + * display_links payload: clickable http(s) URLs constructed outside the model. + * + * Stored href bytes must equal the caller-constructed string. Do not canonicalize + * via `new URL().href` (that can add trailing slashes / lowercase hosts). + */ + +export const DISPLAY_LINKS_PAYLOAD_TYPE = 'display-links' as const + +export const MAX_DISPLAY_LINKS = 20 +export const MAX_DISPLAY_LINK_HREF_LENGTH = 2048 +export const MAX_DISPLAY_LINK_TITLE_LENGTH = 255 + +const DENY_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file']) + +export type DisplayLink = { + href: string + title?: string +} + +export type DisplayLinksPayload = { + type: typeof DISPLAY_LINKS_PAYLOAD_TYPE + urls: DisplayLink[] + id: string +} + +/** + * Extract a scheme the same way markdown-text classifyScheme does: up to two + * decodeURIComponent passes, then strip ASCII controls/whitespace from the + * scheme name so `java\nscript:` cannot bypass the deny list. + */ +export function displayLinkScheme(href: string): string | null { + let value = href.trimStart() + for (let i = 0; i < 2; i++) { + try { + const next = decodeURIComponent(value) + if (next === value) break + value = next + } catch { + break + } + } + const colonIndex = value.indexOf(':') + if (colonIndex <= 0) return null + const boundaryIdx = value.search(/[/?#]/) + if (boundaryIdx >= 0 && boundaryIdx < colonIndex) return null + return value.slice(0, colonIndex).replace(/[\x00-\x1F\x7F\s]/g, '').toLowerCase() +} + +export function isDisplayableHttpHref(href: string): boolean { + if (typeof href !== 'string') return false + if (href.length === 0 || href.length > MAX_DISPLAY_LINK_HREF_LENGTH) return false + const scheme = displayLinkScheme(href) + if (scheme === null) return false + if (DENY_SCHEMES.has(scheme)) return false + if (scheme !== 'http' && scheme !== 'https') return false + try { + const parsed = new URL(href.trim()) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +function normalizeTitle(title: unknown): string | undefined { + if (typeof title !== 'string') return undefined + const trimmed = title.trim() + if (!trimmed) return undefined + return trimmed.length > MAX_DISPLAY_LINK_TITLE_LENGTH + ? trimmed.slice(0, MAX_DISPLAY_LINK_TITLE_LENGTH) + : trimmed +} + +export function parseDisplayLink(input: unknown): DisplayLink | null { + if (typeof input === 'string') { + const href = input.trim() + if (!isDisplayableHttpHref(href)) return null + return { href } + } + if (!input || typeof input !== 'object') return null + const record = input as Record + const rawHref = record.href ?? record.url + if (typeof rawHref !== 'string') return null + const href = rawHref.trim() + if (!isDisplayableHttpHref(href)) return null + const title = normalizeTitle(record.title) + return title ? { href, title } : { href } +} + +export function parseDisplayLinksInput(input: unknown): DisplayLink[] { + if (!Array.isArray(input)) { + throw new Error('display_links requires urls: [{ href, title? }]') + } + if (input.length === 0) { + throw new Error('display_links requires at least one URL') + } + if (input.length > MAX_DISPLAY_LINKS) { + throw new Error(`display_links accepts at most ${MAX_DISPLAY_LINKS} URLs`) + } + const urls: DisplayLink[] = [] + for (const item of input) { + const parsed = parseDisplayLink(item) + if (!parsed) { + throw new Error('display_links rejected a URL (http/https only; javascript/data/vbscript/file denied)') + } + urls.push(parsed) + } + return urls +} + +export function safeParseDisplayLinksInput(input: unknown): DisplayLink[] { + if (!Array.isArray(input)) return [] + const urls: DisplayLink[] = [] + for (const item of input.slice(0, MAX_DISPLAY_LINKS)) { + const parsed = parseDisplayLink(item) + if (parsed) urls.push(parsed) + } + return urls +} + +export function buildDisplayLinksPayload(args: { + urls: DisplayLink[] + id: string +}): DisplayLinksPayload { + return { + type: DISPLAY_LINKS_PAYLOAD_TYPE, + urls: args.urls, + id: args.id, + } +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 3e35fdd0bb..d9eb32da85 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -16,6 +16,7 @@ export * from './search' export * from './socket' export * from './sessionSummary' export * from './sessionCitation' +export * from './displayLinks' export * from './sessionLifecycle' export * from './sessionExport' export * from './piThinkingLevel' diff --git a/web/src/chat/normalizeAgent.test.ts b/web/src/chat/normalizeAgent.test.ts index 55a3a13e30..62fc5d5c2c 100644 --- a/web/src/chat/normalizeAgent.test.ts +++ b/web/src/chat/normalizeAgent.test.ts @@ -280,3 +280,41 @@ describe('normalizeAgentRecord — Codex usage provenance', () => { }) }) }) + +describe('normalizeAgentRecord — display-links', () => { + it('round-trips a concatenated landmine href as agent content, not a user turn', () => { + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + const normalized = normalizeAgentRecord('msg-links', null, 1, { + type: 'codex', + data: { + type: 'display-links', + id: 'link-1', + urls: [{ href, title: 'Issue 1516' }] + } + }) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [{ + type: 'display-links', + urls: [{ href: 'https://github.com/tiann/hapi/issues/1516', title: 'Issue 1516' }] + }] + }) + expect(normalized?.role).not.toBe('user') + const urls = normalized && normalized.role === 'agent' + ? normalized.content.filter((c) => c.type === 'display-links') + : [] + expect(urls[0] && urls[0].type === 'display-links' ? urls[0].urls[0]?.href : null).toBe(href) + }) + + it('drops display-links payloads that contain only denied schemes', () => { + const normalized = normalizeAgentRecord('msg-evil', null, 1, { + type: 'codex', + data: { + type: 'display-links', + urls: [{ href: 'javascript:alert(1)' }] + } + }) + expect(normalized).toBeNull() + }) +}) diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 3684c2fdcf..2976bb125a 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -1,6 +1,6 @@ import type { AgentEvent, CodexReview, CodexReviewFinding, NormalizedAgentContent, NormalizedMessage, RoundModelUsage, RoundSummary, ToolResultPermission, UsageData } from '@/chat/types' import { inlineMediaSourceFromWire } from '@/chat/inlineMediaSource' -import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject } from '@hapi/protocol' +import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject, safeParseDisplayLinksInput } from '@hapi/protocol' import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages' import { parseAgentTimestampMs } from '@/chat/agentTimestamp' @@ -1016,6 +1016,26 @@ export function normalizeAgentRecord( } } + if (data.type === 'display-links') { + const urls = safeParseDisplayLinksInput(data.urls) + if (urls.length === 0) return null + const uuid = asString(data.id) ?? messageId + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'display-links', + urls, + uuid, + parentUUID: null, + }], + meta + } + } + if (data.type === 'generated-image') { const imageId = asString(data.imageId ?? data.image_id) if (!imageId) return null diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 601adabd76..5ab0fd06cf 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -5,6 +5,7 @@ import type { AgentTextBlock, ChatBlock, GeneratedImageBlock, + DisplayLinksBlock, CliOutputBlock, CodexReviewBlock, ToolCallBlock, @@ -190,6 +191,19 @@ function areGeneratedImageBlocksEqual(left: GeneratedImageBlock, right: Generate && areRoundSummariesEqual(left.roundSummary, right.roundSummary) } +function areDisplayLinksBlocksEqual(left: DisplayLinksBlock, right: DisplayLinksBlock): boolean { + if (left.localId !== right.localId || left.createdAt !== right.createdAt || left.meta !== right.meta) { + return false + } + if (left.urls.length !== right.urls.length) return false + for (let i = 0; i < left.urls.length; i += 1) { + if (left.urls[i]?.href !== right.urls[i]?.href || left.urls[i]?.title !== right.urls[i]?.title) { + return false + } + } + return true +} + function areCodexReviewBlocksEqual(left: CodexReviewBlock, right: CodexReviewBlock): boolean { return left.review === right.review && left.localId === right.localId @@ -284,6 +298,11 @@ function reconcileBlock(block: ChatBlock, prevById: ChatBlocksById): ChatBlock { return areGeneratedImageBlocksEqual(prevBlock, block) ? prevBlock : block } + if (block.kind === 'display-links') { + const prevBlock = prev as DisplayLinksBlock + return areDisplayLinksBlocksEqual(prevBlock, block) ? prevBlock : block + } + if (block.kind === 'codex-review') { const prevBlock = prev as CodexReviewBlock return areCodexReviewBlocksEqual(prevBlock, block) ? prevBlock : block diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts index 33e0a2c10c..7e3c7b80d6 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -1614,4 +1614,30 @@ describe('reduceTimeline', () => { activity: 'Completed: ok' }) }) + + it('renders display-links agent content as a dedicated timeline block', () => { + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + const { blocks } = reduceTimeline([ + { + id: 'msg-links', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ + type: 'display-links', + urls: [{ href, title: 'Issue 1516' }], + uuid: 'u-links', + parentUUID: null + }], + isSidechain: false + } as TracedMessage + ], makeContext()) + + expect(blocks).toHaveLength(1) + expect(blocks[0]).toMatchObject({ + kind: 'display-links', + urls: [{ href: 'https://github.com/tiann/hapi/issues/1516', title: 'Issue 1516' }] + }) + expect(blocks[0].kind).not.toBe('user-text') + }) }) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 6cdf38ccfa..3e13d9670e 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -901,6 +901,19 @@ export function reduceTimeline( continue } + if (c.type === 'display-links') { + blocks.push({ + kind: 'display-links', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + urls: c.urls, + meta: msg.meta + }) + continue + } + if (c.type === 'reasoning') { const streamId = nonBlank(c.streamId) if (streamId) { diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index e0bcdd3e36..1d63c0744a 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -94,6 +94,18 @@ export type GeneratedImageContent = { source?: InlineMediaSource } +export type DisplayLinkItem = { + href: string + title?: string +} + +export type DisplayLinksContent = { + type: 'display-links' + urls: DisplayLinkItem[] + uuid: string + parentUUID: string | null +} + export type CodexReviewFinding = { title: string body: string @@ -129,6 +141,7 @@ export type NormalizedAgentContent = | ToolUse | ToolResult | GeneratedImageContent + | DisplayLinksContent | { type: 'codex-review' review: CodexReview @@ -302,6 +315,16 @@ export type GeneratedImageBlock = { meta?: unknown } +export type DisplayLinksBlock = { + kind: 'display-links' + id: string + localId: string | null + createdAt: number + invokedAt?: number | null + urls: DisplayLinkItem[] + meta?: unknown +} + export type AgentEventBlock = { kind: 'agent-event' id: string @@ -327,4 +350,4 @@ export type ToolCallBlock = { meta?: unknown } -export type ChatBlock = UserTextBlock | AgentTextBlock | AgentReasoningBlock | CodexReviewBlock | CliOutputBlock | ToolCallBlock | GeneratedImageBlock | AgentEventBlock +export type ChatBlock = UserTextBlock | AgentTextBlock | AgentReasoningBlock | CodexReviewBlock | CliOutputBlock | ToolCallBlock | GeneratedImageBlock | DisplayLinksBlock | AgentEventBlock diff --git a/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx new file mode 100644 index 0000000000..63bbfd64bd --- /dev/null +++ b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { DisplayLinksCard } from '@/components/AssistantChat/messages/ToolMessage' + +describe('DisplayLinksCard', () => { + it('paints the constructed href without reconstructing from prose', () => { + const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' + render( + + ) + + const link = screen.getByRole('link', { name: /Issue 1516/ }) + expect(link).toHaveAttribute('href', 'https://github.com/tiann/hapi/issues/1516') + expect(link.getAttribute('href')).toBe(href) + expect(link.getAttribute('href')).not.toContain('tian/hapi') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('does not make javascript hrefs tappable', () => { + render( + + ) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByText('evil')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index e977fb503e..a2ca144c8e 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react' import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' -import type { GeneratedImageBlock, ToolCallBlock } from '@/chat/types' +import type { DisplayLinksBlock, GeneratedImageBlock, ToolCallBlock } from '@/chat/types' import type { ToolGroupBlock } from '@/chat/toolGroups' -import { isObject, safeStringify } from '@hapi/protocol' +import { isDisplayableHttpHref, isObject, safeStringify } from '@hapi/protocol' import { isSubagentToolName } from '@/chat/subagentTool' import { ToolGroupCard } from '@/components/ToolCard/ToolGroupCard' import { getEventPresentation } from '@/chat/presentation' @@ -52,6 +52,65 @@ function isGeneratedImageBlock(value: unknown): value is GeneratedImageBlock { return true } +function isDisplayLinksBlock(value: unknown): value is DisplayLinksBlock { + if (!isObject(value)) return false + if (value.kind !== 'display-links') return false + if (typeof value.id !== 'string') return false + if (!Array.isArray(value.urls)) return false + return true +} + +/** Exported for display-links renderer tests. */ +export function DisplayLinksCard(props: { block: DisplayLinksBlock }) { + return ( +
+
+ Links +
+ +
+ ) +} + const MIN_INLINE_IMAGE_DIMENSION = 64 /** Scale tiny icons up for readability without exploding skinny/tall images. */ @@ -273,6 +332,14 @@ function HappyNestedBlockList(props: { ) } + if (block.kind === 'display-links') { + return ( +
+ +
+ ) + } + if (block.kind === 'agent-event') { const presentation = getEventPresentation(block.event) return ( @@ -361,6 +428,14 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { ) } + if (isDisplayLinksBlock(artifact)) { + return ( +
+ +
+ ) + } + if (!isToolCallBlock(artifact)) { const argsText = typeof props.argsText === 'string' ? props.argsText.trim() : '' const hasArgsText = argsText.length > 0 diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index f95f110560..1c7485fb27 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -555,6 +555,28 @@ function toThreadMessageLike( } } + if (block.kind === 'display-links') { + return { + role: 'assistant', + id: threadMessageId, + createdAt: new Date(timestamp), + content: [{ + type: 'tool-call', + toolCallId: block.id, + toolName: 'DisplayLinks', + argsText: '', + artifact: block + }], + metadata: { + custom: { + kind: 'tool', + toolCallId: block.id, + invokedAt: block.invokedAt ?? null + } satisfies HappyChatMessageMetadata + } + } + } + if (block.kind === 'agent-reasoning') { return { role: 'assistant', diff --git a/web/src/lib/sessionExport/markdown.ts b/web/src/lib/sessionExport/markdown.ts index 1d50ebb653..851d585aab 100644 --- a/web/src/lib/sessionExport/markdown.ts +++ b/web/src/lib/sessionExport/markdown.ts @@ -93,6 +93,8 @@ function formatAgentContentBlock(block: NormalizedAgentContent): string | null { } case 'generated-image': return `- Generated image: ${block.fileName}` + case 'display-links': + return block.urls.map((url) => `- Link: ${url.title ? `${url.title} (${url.href})` : url.href}`).join('\n') case 'codex-review': return `- Codex review: ${block.review.overallCorrectness ?? 'review'} (${block.review.findings.length} findings)` case 'summary': From 4d76ebb921d3b5aeab32c20ba2111a71b43e25f8 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:43:31 +0000 Subject: [PATCH 02/26] fix(display-links): register MCP tool on Cursor sessions only Doubled-letter URL recall is a Cursor-routed failure mode. Keep the paint path and CLI fallback, but do not advertise display_links to Claude/Codex/OpenCode/Grok tool lists or system prompts. Co-authored-by: Cursor --- AGENTS.md | 2 +- cli/src/agent/hapiSessionEnv.ts | 2 +- cli/src/agent/runners/runAgentSession.test.ts | 5 +- cli/src/agent/runners/runAgentSession.ts | 1 + cli/src/claude/utils/startHappyServer.test.ts | 43 +++++--- cli/src/claude/utils/startHappyServer.ts | 98 +++++++++++-------- cli/src/claude/utils/systemPrompt.ts | 3 +- cli/src/codex/happyMcpStdioBridge.ts | 5 +- .../codex/utils/buildHapiMcpBridge.test.ts | 33 +++++-- cli/src/codex/utils/buildHapiMcpBridge.ts | 14 ++- cli/src/codex/utils/systemPrompt.ts | 3 +- cli/src/cursor/cursorAcpRemoteLauncher.ts | 1 + cli/src/grok/utils/systemPrompt.ts | 3 +- cli/src/modules/common/displayImagePrompt.ts | 12 --- cli/src/modules/common/hapiMcpBridgePrompt.ts | 5 +- cli/src/opencode/utils/systemPrompt.ts | 2 - cli/src/ui/doctorInlineMedia.ts | 4 +- 17 files changed, 138 insertions(+), 98 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 40e1851b47..7631fea1d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Start with the task's files; read only relevant sections of these references, no | Native API contract, chat conformance | [client contract](docs/api/client-contract/index.md), [iOS](ios/README.md), [Android](android/README.md) | | Encrypted native push relay | [relay/README.md](relay/README.md) | | User docs / marketing site | `docs/` (VitePress) / `website/` | -| Paint tappable URLs (Cursor) | MCP `display_links` / `hapi display-links` / `scripts/tooling/hapi-display-links.mjs` — construct hrefs by concatenation (`"tia"+"nn"`), never type landmine hosts in prose | +| Paint tappable URLs (Cursor only) | MCP `display_links` (Cursor sessions) or `hapi display-links` / `scripts/tooling/hapi-display-links.mjs` — construct hrefs by concatenation (`"tia"+"nn"`), never type landmine hosts in prose | ## Repository conventions diff --git a/cli/src/agent/hapiSessionEnv.ts b/cli/src/agent/hapiSessionEnv.ts index b26d8f54ba..0cc52a7f81 100644 --- a/cli/src/agent/hapiSessionEnv.ts +++ b/cli/src/agent/hapiSessionEnv.ts @@ -15,7 +15,7 @@ export const HAPI_SESSION_ID_ENV = 'HAPI_SESSION_ID'; * here covers claude / codex / copilot / cursor / gemini / opencode / kimi / grok / pi at * once, including future flavors, without touching each launcher. * - * Prefer the MCP `display_image` / `display_links` tools when they are available; + * Prefer the MCP `display_image` tool when available; Cursor sessions also get `display_links`. * `HAPI_SESSION_ID` is the deterministic fallback for hub REST and shell tooling. * To discover / read / message another session, prefer MCP `list_peers` / * `inspect_peer` / `ping_peer` (or `hapi ping-peer --list` / `inspect-peer` / diff --git a/cli/src/agent/runners/runAgentSession.test.ts b/cli/src/agent/runners/runAgentSession.test.ts index c7e7127c44..0c0277c6db 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -74,7 +74,7 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ harness.startHappyServerOptions = options return { url: 'http://127.0.0.1:1234', - toolNames: ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'], + toolNames: ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'], stop: harness.stopServer } }) @@ -157,6 +157,7 @@ describe('runAgentSession', () => { await running expect(harness.startHappyServerOptions).toEqual({ + enableDisplayLinks: false, skillLookup: { workingDirectory: '/tmp/project', flavor: 'acp' @@ -167,7 +168,7 @@ describe('runAgentSession', () => { '--url', 'http://127.0.0.1:1234', '--tools', - 'change_title,display_image,display_video,display_media,display_links,list_peers,ping_peer,inspect_peer,skill_lookup' + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup' ]) expect(harness.newSessionOptions).toMatchObject({ cwd: '/tmp/project', diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index efd33c28b3..40bedf7580 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -70,6 +70,7 @@ export async function runAgentSession(opts: { const permissionAdapter = new PermissionAdapter(session, backend, () => currentPermissionMode); const happyServer = await startHappyServer(session, { + enableDisplayLinks: opts.agentType === 'cursor', skillLookup: { workingDirectory, flavor: opts.agentType diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 7f0e901165..5cc981874e 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -41,21 +41,25 @@ describe('startHappyServer skill_lookup', () => { await rm(sandboxDir, { recursive: true, force: true }) }) - async function connect(enableSkillLookup = true): Promise { + async function connect(enableSkillLookup = true, extra: { enableDisplayLinks?: boolean; flavor?: string } = {}): Promise { sendAgentMessage = vi.fn() const sessionClient = { updateMetadata: vi.fn(), sendAgentMessage, sendClaudeSessionMessage: vi.fn() } as unknown as ApiSessionClient - const server = await startHappyServer(sessionClient, enableSkillLookup - ? { - skillLookup: { - workingDirectory, - flavor: 'opencode' + const { flavor, enableDisplayLinks } = extra + const server = await startHappyServer(sessionClient, { + ...(enableSkillLookup + ? { + skillLookup: { + workingDirectory, + flavor: flavor ?? 'opencode' + } } - } - : {}) + : {}), + ...(enableDisplayLinks !== undefined ? { enableDisplayLinks } : {}), + }) stopServer = server.stop client = new Client( @@ -112,11 +116,11 @@ describe('startHappyServer skill_lookup', () => { 'display_image', 'display_video', 'display_media', - 'display_links', 'ping_peer', 'inspect_peer', 'list_peers' ]) + expect(tools.tools.map((tool) => tool.name)).not.toContain('display_links') }) it('describes display_image as user output rather than image input', async () => { @@ -169,8 +173,20 @@ describe('startHappyServer skill_lookup', () => { })) }) + it('does not expose display_links for non-cursor flavors', async () => { + const mcp = await connect(true, { flavor: 'opencode' }) + const tools = await mcp.listTools() + expect(tools.tools.map((tool) => tool.name)).not.toContain('display_links') + }) + + it('exposes display_links for cursor flavor', async () => { + const mcp = await connect(true, { flavor: 'cursor' }) + const tools = await mcp.listTools() + expect(tools.tools.map((tool) => tool.name)).toContain('display_links') + }) + it('paints display_links via sendAgentMessage with concatenated href bytes', async () => { - const mcp = await connect(false) + const mcp = await connect(false, { enableDisplayLinks: true }) const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' const result = await mcp.callTool({ @@ -190,7 +206,7 @@ describe('startHappyServer skill_lookup', () => { }) it('rejects javascript hrefs without emitting an agent message', async () => { - const mcp = await connect(false) + const mcp = await connect(false, { enableDisplayLinks: true }) const result = await mcp.callTool({ name: 'display_links', arguments: { urls: [{ href: 'javascript:alert(1)' }] } @@ -214,12 +230,11 @@ describe('startHappyServer skill_lookup', () => { await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url))) const tools = await mcp.listTools() - expect(server.toolNames).toEqual(['display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer']) + expect(server.toolNames).toEqual(['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']) expect(tools.tools.map((tool) => tool.name)).toEqual([ 'display_image', 'display_video', 'display_media', - 'display_links', 'ping_peer', 'inspect_peer', 'list_peers' @@ -235,7 +250,6 @@ describe('toClaudeAllowedHapiMcpTools', () => { 'display_image', 'display_video', 'display_media', - 'display_links', 'list_peers', 'ping_peer', 'inspect_peer', @@ -243,7 +257,6 @@ describe('toClaudeAllowedHapiMcpTools', () => { ])).toEqual([ 'mcp__hapi__change_title', 'mcp__hapi__display_image', - 'mcp__hapi__display_links', 'mcp__hapi__list_peers', 'mcp__hapi__skill_lookup' ]) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 93e648b8f3..c7e2d96895 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -32,12 +32,24 @@ import { PingPeerError, formatInspectPeerReport, formatPeerSessionsList, inspect type StartHappyServerOptions = { emitTitleSummary?: boolean; enableChangeTitle?: boolean; + /** + * Cursor-only (#1516): doubled-letter URL recall is a Cursor-routed failure mode. + * Defaults on when skillLookup.flavor === 'cursor'. + */ + enableDisplayLinks?: boolean; skillLookup?: { workingDirectory: string; flavor: string; }; }; +function resolveEnableDisplayLinks(options: StartHappyServerOptions): boolean { + if (options.enableDisplayLinks !== undefined) { + return options.enableDisplayLinks; + } + return options.skillLookup?.flavor === 'cursor'; +} + /** Registered on the MCP server, but never pre-approved via Claude --allowedTools. */ const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set([ 'display_media', @@ -62,7 +74,8 @@ function createHapiMcpServer( client: ApiSessionClient, emitTitleSummary: boolean, enableChangeTitle: boolean, - skillLookup: StartHappyServerOptions['skillLookup'] + skillLookup: StartHappyServerOptions['skillLookup'], + enableDisplayLinks: boolean ): McpServer { const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); @@ -302,43 +315,45 @@ function createHapiMcpServer( } }); - mcp.registerTool('display_links', { - description: `Paint clickable http(s) URL cards into the current HAPI chat without a fake user turn. ${DISPLAY_LINKS_PROMPT_CURSOR}`, - title: 'Display Links', - inputSchema: displayLinksInputSchema, - }, async (args: { urls: Array<{ href: string; title?: string } | string> }) => { - logger.debug('[hapiMCP] Display links:', args.urls); + if (enableDisplayLinks) { + mcp.registerTool('display_links', { + description: `Paint clickable http(s) URL cards into the current HAPI chat without a fake user turn. Cursor-only: other flavors type URLs fine. ${DISPLAY_LINKS_PROMPT_CURSOR}`, + title: 'Display Links', + inputSchema: displayLinksInputSchema, + }, async (args: { urls: Array<{ href: string; title?: string } | string> }) => { + logger.debug('[hapiMCP] Display links:', args.urls); - try { - const urls = parseDisplayLinksInput(args.urls); - client.sendAgentMessage(buildDisplayLinksPayload({ - urls, - id: randomUUID(), - })); + try { + const urls = parseDisplayLinksInput(args.urls); + client.sendAgentMessage(buildDisplayLinksPayload({ + urls, + id: randomUUID(), + })); - return { - content: [ - { - type: 'text' as const, - text: `Displayed ${urls.length} link${urls.length === 1 ? '' : 's'}`, - }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.debug('[hapiMCP] Failed to display links:', message); - return { - content: [ - { - type: 'text' as const, - text: `Failed to display links: ${message}`, - }, - ], - isError: true, - }; - } - }); + return { + content: [ + { + type: 'text' as const, + text: `Displayed ${urls.length} link${urls.length === 1 ? '' : 's'}`, + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.debug('[hapiMCP] Failed to display links:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to display links: ${message}`, + }, + ], + isError: true, + }; + } + }); + } mcp.registerTool('ping_peer', { description: PING_PEER_TOOL_DESCRIPTION, @@ -524,11 +539,12 @@ function readMcpSessionId(req: IncomingMessage): string | undefined { export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { const emitTitleSummary = options.emitTitleSummary ?? true; const enableChangeTitle = options.enableChangeTitle ?? true; + const enableDisplayLinks = resolveEnableDisplayLinks(options); const transports = new Map(); const mcps = new Map(); const createMcpTransport = () => { - const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup); + const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup, enableDisplayLinks); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { @@ -583,8 +599,12 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH })); const toolNames = enableChangeTitle - ? ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer'] - : ['display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer']; + ? ['change_title', 'display_image', 'display_video', 'display_media'] + : ['display_image', 'display_video', 'display_media']; + if (enableDisplayLinks) { + toolNames.push('display_links'); + } + toolNames.push('list_peers', 'ping_peer', 'inspect_peer'); if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 8833a26911..3174ac6edd 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,7 +1,7 @@ import { trimIdent } from "@/utils/trimIdent"; import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCitation"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; -import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_LINKS_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; +import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; /** @@ -12,7 +12,6 @@ const BASE_SYSTEM_PROMPT = (() => trimIdent(` ${DISPLAY_IMAGE_PROMPT_CLAUDE} ${DISPLAY_VIDEO_PROMPT_CLAUDE} ${DISPLAY_MEDIA_PROMPT_CLAUDE} - ${DISPLAY_LINKS_PROMPT_CLAUDE} ${buildSessionCitationSteerInstruction({ inspectTool: 'mcp__hapi__inspect_peer', pingTool: 'mcp__hapi__ping_peer', diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index a39b4034c2..f379977e14 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -1,7 +1,8 @@ /** * HAPI MCP STDIO Bridge * - * Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `display_links`, `list_peers`, `ping_peer`, and `inspect_peer`. + * Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `list_peers`, `ping_peer`, and `inspect_peer`. + * `display_links` is Cursor-only and is registered only when `--tools` includes it. * On invocation it forwards the tool call to an existing HAPI HTTP MCP server * using the StreamableHTTPClientTransport. * @@ -23,7 +24,7 @@ import { SESSION_ID_PREFIX_PARAM_DESCRIPTION, } from '@hapi/protocol/sessionCitation'; -const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer']; +const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; function parseArgs(argv: string[]): { url: string | null; toolNames: Set } { let url: string | null = null; diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index 85143d80e6..0254c5364c 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -9,13 +9,20 @@ const harness = vi.hoisted(() => ({ })) vi.mock('@/claude/utils/startHappyServer', () => ({ - startHappyServer: vi.fn(async (_client: unknown, options: { skillLookup?: unknown }) => { + startHappyServer: vi.fn(async (_client: unknown, options: { + skillLookup?: { flavor?: string } + enableDisplayLinks?: boolean + }) => { harness.startOptions = options + const cursorLinks = options.enableDisplayLinks === true + || (options.enableDisplayLinks !== false && options.skillLookup?.flavor === 'cursor') + const names = ['change_title', 'display_image', 'display_video', 'display_media'] + if (cursorLinks) names.push('display_links') + names.push('list_peers', 'ping_peer', 'inspect_peer') + if (options.skillLookup) names.push('skill_lookup') return { url: 'http://127.0.0.1:43006/', - toolNames: options.skillLookup - ? ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'] - : ['change_title', 'display_image', 'display_video', 'display_media', 'display_links', 'list_peers', 'ping_peer', 'inspect_peer'], + toolNames: names, stop: vi.fn() } }) @@ -64,6 +71,7 @@ describe('buildHapiMcpBridge skill lookup config', () => { expect(harness.startOptions).toEqual({ emitTitleSummary: undefined, + enableDisplayLinks: undefined, skillLookup }) expect(harness.cliArgs).toEqual([ @@ -71,31 +79,40 @@ describe('buildHapiMcpBridge skill lookup config', () => { '--url', 'http://127.0.0.1:43006/', '--tools', - 'change_title,display_image,display_video,display_media,display_links,list_peers,ping_peer,inspect_peer,skill_lookup' + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup' ]) expect(bridge.mcpServers.hapi.tools).toEqual({ change_title: { approval_mode: 'approve' }, display_image: { approval_mode: 'prompt' }, display_video: { approval_mode: 'prompt' }, display_media: { approval_mode: 'prompt' }, - display_links: { approval_mode: 'approve' }, list_peers: { approval_mode: 'approve' }, skill_lookup: { approval_mode: 'approve' } }) + expect(bridge.mcpServers.hapi.tools).not.toHaveProperty('display_links') }) it('does not expose skill_lookup for native-skill bridge callers', async () => { const bridge = await buildHapiMcpBridge(createClient()) - expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,display_video,display_media,display_links,list_peers,ping_peer,inspect_peer') + expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer') expect(bridge.mcpServers.hapi.tools).toEqual({ change_title: { approval_mode: 'approve' }, display_image: { approval_mode: 'prompt' }, display_video: { approval_mode: 'prompt' }, display_media: { approval_mode: 'prompt' }, - display_links: { approval_mode: 'approve' }, list_peers: { approval_mode: 'approve' } }) + expect(bridge.mcpServers.hapi.tools).not.toHaveProperty('display_links') + }) + + it('auto-approves display_links for cursor sessions', async () => { + const bridge = await buildHapiMcpBridge(createClient(), { + enableDisplayLinks: true, + skillLookup: { workingDirectory: '/repo', flavor: 'cursor' } + }) + expect(harness.cliArgs.at(-1)).toContain('display_links') + expect(bridge.mcpServers.hapi.tools?.display_links).toEqual({ approval_mode: 'approve' }) }) it('materializes pending lazy sessions before starting the MCP server', async () => { diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index a1ac26d207..1d999c39b6 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -48,6 +48,8 @@ export interface HapiMcpBridgeOptions { exportSessionEnv?: boolean; emitTitleSummary?: boolean; enableChangeTitle?: boolean; + /** Cursor-only (#1516). Also inferred from skillLookup.flavor === 'cursor'. */ + enableDisplayLinks?: boolean; skillLookup?: { workingDirectory: string; flavor: string; @@ -82,6 +84,7 @@ export async function buildHapiMcpBridge( const happyServer = await startHappyServer(client, { emitTitleSummary: options.emitTitleSummary, enableChangeTitle: options.enableChangeTitle, + enableDisplayLinks: options.enableDisplayLinks, skillLookup: options.skillLookup }); const bridgeCommand = getHappyCliCommand([ @@ -100,12 +103,15 @@ export async function buildHapiMcpBridge( }, display_media: { approval_mode: 'prompt' - }, - // No local-file read — auto-approve so models actually use it instead of typing URLs. - display_links: { - approval_mode: 'approve' } }; + // Cursor-only (#1516) — no local-file read; auto-approve so the model uses it + // instead of typing doubled-letter-mangled URLs. + if (happyServer.toolNames.includes('display_links')) { + tools.display_links = { + approval_mode: 'approve' + }; + } if (options.enableChangeTitle !== false) { tools.change_title = { approval_mode: 'approve' diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index de60ea5155..bd85efc36c 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -7,7 +7,7 @@ import { trimIdent } from '@/utils/trimIdent'; import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; -import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_LINKS_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; +import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** @@ -24,7 +24,6 @@ export const TITLE_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_CODEX} ${DISPLAY_VIDEO_PROMPT_CODEX} ${DISPLAY_MEDIA_PROMPT_CODEX} - ${DISPLAY_LINKS_PROMPT_CODEX} ${buildSessionCitationSteerInstruction({ inspectTool: 'functions.hapi__inspect_peer', pingTool: 'functions.hapi__ping_peer', diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 8ac1c978eb..a455b7a06d 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -110,6 +110,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { enableChangeTitle: false, + enableDisplayLinks: true, skillLookup: { workingDirectory: session.path, flavor: 'cursor' } }); this.happyServer = happyServer; diff --git a/cli/src/grok/utils/systemPrompt.ts b/cli/src/grok/utils/systemPrompt.ts index 044b21051e..695bc9ea42 100644 --- a/cli/src/grok/utils/systemPrompt.ts +++ b/cli/src/grok/utils/systemPrompt.ts @@ -1,9 +1,8 @@ -import { DISPLAY_LINKS_PROMPT_HAPI_MCP } from '@/modules/common/displayImagePrompt' import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction' import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction' export const GROK_TITLE_INSTRUCTION = - `Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.\n${DISPLAY_LINKS_PROMPT_HAPI_MCP}\n${SKILL_LOOKUP_INSTRUCTION}` + `Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.\n${SKILL_LOOKUP_INSTRUCTION}` export function getGrokTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { return withSessionSummaryInstruction(GROK_TITLE_INSTRUCTION, env) diff --git a/cli/src/modules/common/displayImagePrompt.ts b/cli/src/modules/common/displayImagePrompt.ts index e91ad42512..6f068f5f89 100644 --- a/cli/src/modules/common/displayImagePrompt.ts +++ b/cli/src/modules/common/displayImagePrompt.ts @@ -56,18 +56,6 @@ export const DISPLAY_MEDIA_PROMPT_CURSOR = trimIdent(` When you create or find a local audio file or other non-image file that the user should receive, call the tool "display_media" with the absolute filesystem path so HAPI can show a player or download card. `); -export const DISPLAY_LINKS_PROMPT_CLAUDE = trimIdent(` - When the operator needs a tappable URL in HAPI chat, call "mcp__hapi__display_links" with { urls: [{ href, title? }] }. Do not type URLs in assistant prose — models drop doubled letters when recalling hosts (tiann→tian) and operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. -`); - -export const DISPLAY_LINKS_PROMPT_CODEX = trimIdent(` - When the operator needs a tappable URL in HAPI chat, call functions.hapi__display_links with { urls: [{ href, title? }] }. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_links, mcp__hapi__display_links, or hapi_display_links. Do not type URLs in assistant prose — models drop doubled letters when recalling hosts (tiann→tian) and operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. -`); - -export const DISPLAY_LINKS_PROMPT_HAPI_MCP = trimIdent(` - When the operator needs a tappable URL in HAPI chat, call the tool "hapi_display_links" with { urls: [{ href, title? }] }. If that exact tool name is unavailable, use an equivalent alias such as display_links or mcp__hapi__display_links. Do not type URLs in assistant prose — models drop doubled letters when recalling hosts (tiann→tian) and operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. -`); - export const DISPLAY_LINKS_PROMPT_CURSOR = trimIdent(` When the operator needs a tappable URL in this HAPI chat, call the tool "display_links" with { urls: [{ href, title? }] }. Do not type URLs in assistant prose — Cursor-routed models drop doubled letters when recalling hosts (tiann→tian, MagicDNS labels) and headset operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. `); diff --git a/cli/src/modules/common/hapiMcpBridgePrompt.ts b/cli/src/modules/common/hapiMcpBridgePrompt.ts index f6fb4d273d..3017c362c2 100644 --- a/cli/src/modules/common/hapiMcpBridgePrompt.ts +++ b/cli/src/modules/common/hapiMcpBridgePrompt.ts @@ -1,16 +1,14 @@ import { trimIdent } from '@/utils/trimIdent'; import { DISPLAY_IMAGE_PROMPT_HAPI_MCP, - DISPLAY_LINKS_PROMPT_HAPI_MCP, DISPLAY_MEDIA_PROMPT_HAPI_MCP, DISPLAY_VIDEO_PROMPT_HAPI_MCP, } from './displayImagePrompt'; /** Shell fallback for `hapi doctor inline-media` only — not injected into agent prompts. */ export const INLINE_MEDIA_SHELL_FALLBACK = trimIdent(` - If display_image / display_video / display_media / display_links tools are not in your tool list, inline media via shell from the HAPI repo (needs bun + @modelcontextprotocol/sdk): + If display_image / display_video / display_media tools are not in your tool list, inline media via shell from the HAPI repo (needs bun + @modelcontextprotocol/sdk): cd && bun scripts/tooling/hapi-display-image.mjs "title" - For clickable URLs (never type landmine hosts in prose): bun scripts/tooling/hapi-display-links.mjs "title" Use the HAPI session uuid prefix from the web URL /sessions/ (first 8 chars), not cursorSessionId or other agent-native ids. Run hapi doctor inline-media to list active bridges and copy the exact command. `); @@ -29,5 +27,4 @@ export const HAPI_MCP_BRIDGE_PROMPT = trimIdent(` ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} ${DISPLAY_MEDIA_PROMPT_HAPI_MCP} - ${DISPLAY_LINKS_PROMPT_HAPI_MCP} `); diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index eb8c9d3ce9..b1838d33e7 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -10,7 +10,6 @@ import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCita import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; import { DISPLAY_IMAGE_PROMPT_HAPI_MCP, - DISPLAY_LINKS_PROMPT_HAPI_MCP, DISPLAY_MEDIA_PROMPT_HAPI_MCP, DISPLAY_VIDEO_PROMPT_HAPI_MCP, } from '@/modules/common/displayImagePrompt'; @@ -42,7 +41,6 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} ${DISPLAY_MEDIA_PROMPT_HAPI_MCP} - ${DISPLAY_LINKS_PROMPT_HAPI_MCP} ${buildSessionCitationSteerInstruction({ inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', diff --git a/cli/src/ui/doctorInlineMedia.ts b/cli/src/ui/doctorInlineMedia.ts index f9dea4b25e..77424ccf39 100644 --- a/cli/src/ui/doctorInlineMedia.ts +++ b/cli/src/ui/doctorInlineMedia.ts @@ -224,7 +224,7 @@ export async function runDoctorInlineMedia(): Promise { if (cursorSessions.length > 0) { console.log(chalk.bold('\nCursor ACP')) console.log(chalk.gray(' Cursor ignores session/new mcpServers. Remote sessions use ~/.cursor/mcp.json + `agent mcp enable hapi-`.')) - console.log(chalk.gray(' Tool names are bare: display_image, display_video, display_media, display_links, change_title (not hapi_display_image).')) + console.log(chalk.gray(' Tool names are bare: display_image, display_video, display_media, display_links (Cursor-only), change_title (not hapi_display_image).')) for (const session of cursorSessions) { const serverId = cursorHapiMcpServerId(session.id) console.log(chalk.gray(` Verify (${session.prefix}): agent mcp list-tools ${serverId}`)) @@ -232,7 +232,7 @@ export async function runDoctorInlineMedia(): Promise { } console.log(chalk.bold('\nAgent inline path')) - console.log(chalk.gray(' 1. MCP tool display_image / display_video / display_media / display_links in the running session (ACP flavors via hapi bridge)')) + console.log(chalk.gray(' 1. MCP tool display_image / display_video / display_media in the running session (ACP flavors via hapi bridge); display_links is Cursor-only')) if (shellFallbackAvailable) { console.log(chalk.gray(' 2. Shell fallback (HAPI session id prefix, not cursorSessionId):')) if (withBridge.length > 0) { From 37a3d4799556dcdb5490341e5ce6b765d76e2bdf Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:07:13 +0000 Subject: [PATCH 03/26] fix(cli): follow relocated ~/.cursor symlink for HAPI MCP overlay Estate hosts bind Cursor state under /var/lib/hapi/cursor via a home symlink; refusing the symlink directory left overlay install dead and pushed agents toward stale project mcp.json sidecars. Resolve to the real directory (and honor HAPI_CURSOR_MCP_CONFIG_DIR); keep refusing symlinked mcp.json files. Co-authored-by: Cursor --- cli/src/cursor/utils/cursorMcpOverlay.test.ts | 51 +++++++++++++++---- cli/src/cursor/utils/cursorMcpOverlay.ts | 43 +++++++++++++--- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/cli/src/cursor/utils/cursorMcpOverlay.test.ts b/cli/src/cursor/utils/cursorMcpOverlay.test.ts index d5a2f182fc..8388c8119c 100644 --- a/cli/src/cursor/utils/cursorMcpOverlay.test.ts +++ b/cli/src/cursor/utils/cursorMcpOverlay.test.ts @@ -6,6 +6,7 @@ import { lstatSync, mkdirSync, readFileSync, + realpathSync, readdirSync, rmSync, statSync, @@ -32,11 +33,17 @@ describe('installCursorMcpOverlay', () => { const roots: string[] = []; /** Unit tests must not shell out to a real Cursor `agent` binary. */ const noopEnable = () => ({ status: 0 }); + const previousMcpConfigDir = process.env.HAPI_CURSOR_MCP_CONFIG_DIR; afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }); } + if (previousMcpConfigDir === undefined) { + delete process.env.HAPI_CURSOR_MCP_CONFIG_DIR; + } else { + process.env.HAPI_CURSOR_MCP_CONFIG_DIR = previousMcpConfigDir; + } }); function makeProjectDir(initialMcpJson?: string): string { @@ -50,11 +57,22 @@ describe('installCursorMcpOverlay', () => { return root; } - it('defaults MCP config dir to ~/.cursor (outside the project tree)', () => { - expect(resolveCursorMcpConfigDir()).toBe(join(homedir(), '.cursor')); + it('defaults MCP config dir to ~/.cursor (following a relocated home symlink)', () => { + delete process.env.HAPI_CURSOR_MCP_CONFIG_DIR; + const homeCursor = join(homedir(), '.cursor'); + const expected = existsSync(homeCursor) ? realpathSync(homeCursor) : homeCursor; + expect(resolveCursorMcpConfigDir()).toBe(expected); expect(resolveCursorMcpConfigDir(' /tmp/custom-cursor ')).toBe('/tmp/custom-cursor'); }); + it('honors HAPI_CURSOR_MCP_CONFIG_DIR when no override is passed', () => { + const custom = join(tmpdir(), `hapi-cursor-mcp-env-${randomUUID()}`); + mkdirSync(custom, { recursive: true }); + roots.push(custom); + process.env.HAPI_CURSOR_MCP_CONFIG_DIR = custom; + expect(resolveCursorMcpConfigDir()).toBe(custom); + }); + it('writes per-session bridge into .cursor/mcp.json and removes only that id on cleanup', () => { const cwd = makeProjectDir(JSON.stringify({ mcpServers: { @@ -383,23 +401,36 @@ describe('installCursorMcpOverlay', () => { expect(readFileSync(realConfig, 'utf-8')).toBe(original); }); - it('refuses a symlinked .cursor directory before mutating MCP config', () => { + it('follows a relocated .cursor directory symlink and writes mcp.json on the real target', () => { const cwd = makeProjectDir(); const realCursorDir = join(cwd, 'real-cursor'); mkdirSync(realCursorDir, { recursive: true }); const externalMcp = join(realCursorDir, 'mcp.json'); - const original = `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`; - writeFileSync(externalMcp, original, 'utf-8'); + writeFileSync(externalMcp, `${JSON.stringify({ + mcpServers: { + other: { command: 'echo', args: ['keep'] }, + }, + }, null, 2)}\n`, 'utf-8'); symlinkSync(realCursorDir, join(cwd, '.cursor')); - expect(() => installCursorMcpOverlay(cwd, { + const serverId = cursorHapiMcpServerId('session-a'); + const handle = installCursorMcpOverlay(cwd, { command: '/bin/hapi', args: ['mcp', '--url', 'http://127.0.0.1:12345/'], - }, { serverId: cursorHapiMcpServerId('session-a'), enableCursorMcp: noopEnable, mcpConfigDir: join(cwd, '.cursor') })).toThrow( - /Refusing to use a symlinked Cursor config directory/ - ); + }, { serverId, enableCursorMcp: noopEnable, mcpConfigDir: join(cwd, '.cursor') }); + + const written = JSON.parse(readFileSync(externalMcp, 'utf-8')) as { + mcpServers: Record; + }; + expect(written.mcpServers[serverId]?.command).toBe('/bin/hapi'); + expect(written.mcpServers.other).toEqual({ command: 'echo', args: ['keep'] }); - expect(readFileSync(externalMcp, 'utf-8')).toBe(original); + handle.cleanup(); + const after = JSON.parse(readFileSync(externalMcp, 'utf-8')) as { + mcpServers: Record; + }; + expect(after.mcpServers[serverId]).toBeUndefined(); + expect(after.mcpServers.other).toEqual({ command: 'echo', args: ['keep'] }); }); it('writeMcpJsonAtomic preserves restrictive mode and cleans up tmp on failure path', () => { diff --git a/cli/src/cursor/utils/cursorMcpOverlay.ts b/cli/src/cursor/utils/cursorMcpOverlay.ts index 7358fa0992..0d50dd9757 100644 --- a/cli/src/cursor/utils/cursorMcpOverlay.ts +++ b/cli/src/cursor/utils/cursorMcpOverlay.ts @@ -12,6 +12,7 @@ import { lstatSync, mkdirSync, readFileSync, + realpathSync, renameSync, rmSync, statSync, @@ -40,10 +41,41 @@ export function cursorHapiMcpServerId(sessionId: string): string { return `hapi-${trimmed}`; } -/** Resolve the Cursor MCP config directory (override for tests; default `~/.cursor`). */ +/** + * Resolve the Cursor MCP config directory. + * + * Precedence: explicit `override` → `HAPI_CURSOR_MCP_CONFIG_DIR` → `~/.cursor`. + * Relocated homes (e.g. `~/.cursor` → `/var/lib/hapi/cursor`) are followed to a + * real directory so the overlay can write `mcp.json` on the target filesystem. + * Symlinked `mcp.json` files are still refused at write time. + */ export function resolveCursorMcpConfigDir(override?: string): string { - const trimmed = override?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : join(homedir(), '.cursor'); + const fromEnv = process.env.HAPI_CURSOR_MCP_CONFIG_DIR?.trim(); + const trimmed = override?.trim() || fromEnv || ''; + const candidate = trimmed.length > 0 ? trimmed : join(homedir(), '.cursor'); + const entry = lstatSync(candidate, { throwIfNoEntry: false }); + if (!entry) { + return candidate; + } + if (entry.isSymbolicLink()) { + let real: string; + try { + real = realpathSync(candidate); + } catch { + throw new Error(`Cursor config directory symlink is dangling: ${candidate}`); + } + const realEntry = lstatSync(real, { throwIfNoEntry: false }); + if (!realEntry?.isDirectory()) { + throw new Error( + `Cursor config directory symlink must resolve to a directory: ${candidate} -> ${real}`, + ); + } + return real; + } + if (!entry.isDirectory()) { + throw new Error(`Cursor config path is not a directory: ${candidate}`); + } + return candidate; } type McpServerEntry = { @@ -285,13 +317,10 @@ export function installCursorMcpOverlay( throw new Error('serverId is required for Cursor HAPI MCP overlay'); } + // resolveCursorMcpConfigDir follows a relocated ~/.cursor symlink to a real dir. const cursorDir = resolveCursorMcpConfigDir(options.mcpConfigDir); const mcpJsonPath = join(cursorDir, 'mcp.json'); const lockPath = `${mcpJsonPath}.hapi.lock`; - const cursorDirEntry = lstatSync(cursorDir, { throwIfNoEntry: false }); - if (cursorDirEntry?.isSymbolicLink()) { - throw new Error(`Refusing to use a symlinked Cursor config directory: ${cursorDir}`); - } mkdirSync(cursorDir, { recursive: true }); const installedHapi: McpServerEntry = { From fc71895ab2c0f879c61137bdf26ffd62cc5a0c48 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:04:35 +0000 Subject: [PATCH 04/26] fix(display-links): refuse cross-runner loopback MCP targeting hapiMcpUrl is 127.0.0.1 on the owning CLI. Opening it for another hub session hits the caller host. Restrict the CLI/script fallback to self. Co-authored-by: Cursor --- cli/src/commands/displayLinks.test.ts | 42 ++++++++++++++++++++++++-- cli/src/commands/displayLinks.ts | 26 ++++++++++++++-- scripts/tooling/hapi-display-links.mjs | 33 +++++++++++++++----- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/cli/src/commands/displayLinks.test.ts b/cli/src/commands/displayLinks.test.ts index 30df05b6d4..465dace34b 100644 --- a/cli/src/commands/displayLinks.test.ts +++ b/cli/src/commands/displayLinks.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { parseDisplayLinksArgs } from './displayLinks' +import { afterEach, describe, expect, it } from 'vitest' +import { assertLocalDisplayLinksTarget, parseDisplayLinksArgs } from './displayLinks' describe('parseDisplayLinksArgs', () => { it('treats a leading http(s) href as self-target', () => { @@ -39,3 +39,41 @@ describe('parseDisplayLinksArgs', () => { expect(() => parseDisplayLinksArgs(['self'])).toThrow(/missing href/) }) }) + +describe('assertLocalDisplayLinksTarget', () => { + const previous = process.env.HAPI_SESSION_ID + const selfId = '2acd2599-525c-4774-825f-09ce7802549d' + + afterEach(() => { + if (previous === undefined) { + delete process.env.HAPI_SESSION_ID + } else { + process.env.HAPI_SESSION_ID = previous + } + }) + + it('allows omitted session arg and self tokens', () => { + process.env.HAPI_SESSION_ID = selfId + expect(() => assertLocalDisplayLinksTarget(null)).not.toThrow() + expect(() => assertLocalDisplayLinksTarget('self')).not.toThrow() + expect(() => assertLocalDisplayLinksTarget('@me')).not.toThrow() + }) + + it('allows targeting the current session by id or prefix', () => { + process.env.HAPI_SESSION_ID = selfId + expect(() => assertLocalDisplayLinksTarget(selfId)).not.toThrow() + expect(() => assertLocalDisplayLinksTarget(selfId.slice(0, 8))).not.toThrow() + }) + + it('refuses another session id so loopback hapiMcpUrl is not opened on the caller', () => { + process.env.HAPI_SESSION_ID = selfId + expect(() => assertLocalDisplayLinksTarget('9b46cfe7-daf1-446a-bb24-a23208ed9e2a')).toThrow( + /current local session/ + ) + }) + + it('refuses an explicit prefix when HAPI_SESSION_ID is unset', () => { + delete process.env.HAPI_SESSION_ID + expect(() => assertLocalDisplayLinksTarget('9b46cfe7')).toThrow(/current local session/) + }) +}) diff --git a/cli/src/commands/displayLinks.ts b/cli/src/commands/displayLinks.ts index 4c2eb107db..54305e42a6 100644 --- a/cli/src/commands/displayLinks.ts +++ b/cli/src/commands/displayLinks.ts @@ -8,6 +8,26 @@ import { initializeToken } from '@/ui/tokenInit' import type { CommandDefinition } from './types' const SELF_TOKENS = new Set(['self', '@self', '@me', 'current', '-']) +const LOCAL_SESSION_ONLY_ERROR = + 'display-links only supports the current local session; run it on the target runner' + +/** + * hapiMcpUrl is loopback on the owning CLI. Opening it for another hub session + * hits the caller host (wrong server / coincident port). Self tokens and the + * current $HAPI_SESSION_ID (full id or prefix) are the only safe targets. + */ +export function assertLocalDisplayLinksTarget( + sessionArg: string | null, + selfSessionId: string = process.env.HAPI_SESSION_ID?.trim() ?? '' +): void { + if (!sessionArg || SELF_TOKENS.has(sessionArg)) { + return + } + if (selfSessionId && (sessionArg === selfSessionId || selfSessionId.startsWith(sessionArg))) { + return + } + throw new Error(LOCAL_SESSION_ONLY_ERROR) +} type ParsedDisplayLinksArgs = { help: boolean @@ -53,10 +73,10 @@ ${chalk.bold('hapi display-links')} - Paint tappable http(s) URL cards into a HA ${chalk.bold('Usage:')} hapi display-links [title] hapi display-links self [title] - hapi display-links [title] ${chalk.bold('Notes:')} - Uses the session MCP bridge (same path as display_image). Does not create a user turn. + Uses this process's session MCP bridge (loopback hapiMcpUrl). Does not create a user turn. + Other-session / cross-runner targeting is refused — run the command on the runner that owns the session. Construct landmine hosts by concatenation in the calling script ("tia"+"nn"), never from model prose. http/https only. javascript/data/vbscript/file are rejected. @@ -158,6 +178,8 @@ export async function handleDisplayLinksCommand(args: string[]): Promise { parsed.title ? [{ href: parsed.href, title: parsed.title }] : [{ href: parsed.href }] ) + assertLocalDisplayLinksTarget(parsed.sessionArg) + await initializeToken() const headers = await authHeaders() const session = await resolveSession(parsed.sessionArg, headers) diff --git a/scripts/tooling/hapi-display-links.mjs b/scripts/tooling/hapi-display-links.mjs index 09334e672b..24ce55de94 100644 --- a/scripts/tooling/hapi-display-links.mjs +++ b/scripts/tooling/hapi-display-links.mjs @@ -8,10 +8,8 @@ * Usage: * # inside a wrapped session (self-targets via $HAPI_SESSION_ID — no list): * bun scripts/tooling/hapi-display-links.mjs [title] - * # explicit self: * bun scripts/tooling/hapi-display-links.mjs self [title] - * # explicit other session: - * bun scripts/tooling/hapi-display-links.mjs [title] + * Other-session / cross-runner targeting is refused (loopback MCP). * * Construct landmine hosts by concatenation in the calling script ("tia"+"nn"), * never copy a URL from model prose. http/https only. @@ -25,6 +23,19 @@ const HAPI_HOST = process.env.HAPI_HOST ?? process.env.HAPI_API_URL ?? 'http://l const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json` const SELF_TOKENS = new Set(['self', '@self', '@me', 'current', '-']) +const LOCAL_SESSION_ONLY_ERROR = + 'display-links only supports the current local session; run it on the target runner' + +function assertLocalDisplayLinksTarget(sessionArg) { + if (!sessionArg || SELF_TOKENS.has(sessionArg)) { + return + } + const selfSessionId = process.env.HAPI_SESSION_ID?.trim() ?? '' + if (selfSessionId && (sessionArg === selfSessionId || selfSessionId.startsWith(sessionArg))) { + return + } + throw new Error(LOCAL_SESSION_ONLY_ERROR) +} function sessionMatchesPrefix(session, prefix) { if (typeof session.id === 'string' && session.id.startsWith(prefix)) { @@ -47,10 +58,9 @@ function looksLikeHref(value) { return typeof value === 'string' && /^https?:\/\//i.test(value) } -// Arg shapes (backward compatible with display-image style): -// [title] → self-target current session -// [title] → self-target, explicit -// [title] → explicit session +// Arg shapes: +// [title] → self-target current session +// [title] → self-target, explicit const args = process.argv.slice(2) let sessionArg let href @@ -66,11 +76,18 @@ if (args.length > 0 && looksLikeHref(args[0]) && !SELF_TOKENS.has(args[0])) { } if (!href) { - console.error('usage: hapi-display-links.mjs [|self] [title]') + console.error('usage: hapi-display-links.mjs [self] [title]') console.error(' or: HAPI_SESSION_ID= hapi-display-links.mjs [title]') process.exit(2) } +try { + assertLocalDisplayLinksTarget(sessionArg) +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(4) +} + const token = process.env.CLI_API_TOKEN ?? JSON.parse(readFileSync(SETTINGS, 'utf8')).cliApiToken if (!token) { console.error('missing CLI_API_TOKEN env and no cliApiToken in settings') From 7731a0e889583706ef2b6d6eead65bbc4ee3f4b8 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:05:15 +0000 Subject: [PATCH 05/26] feat(display-links): paint exact-copy strings via texts cards Cursor drops doubled letters in secrets and tokens the same way it drops them in hosts. Extend display_links with texts so agents construct byte-accurate copy cards in tool args instead of typing them in prose. Co-authored-by: Cursor --- AGENTS.md | 2 +- cli/src/claude/utils/startHappyServer.test.ts | 21 +++ cli/src/claude/utils/startHappyServer.ts | 34 +++-- cli/src/codex/happyMcpStdioBridge.test.ts | 20 +++ cli/src/codex/happyMcpStdioBridge.ts | 11 +- cli/src/commands/displayLinks.test.ts | 31 ++++- cli/src/commands/displayLinks.ts | 62 +++++++-- cli/src/modules/common/displayImagePrompt.ts | 2 +- scripts/tooling/hapi-display-links.mjs | 29 +++- shared/src/displayLinks.test.ts | 72 ++++++++++ shared/src/displayLinks.ts | 74 +++++++++++ web/src/chat/normalizeAgent.test.ts | 25 ++++ web/src/chat/normalizeAgent.ts | 6 +- web/src/chat/reconcile.ts | 8 ++ web/src/chat/reducerTimeline.test.ts | 26 ++++ web/src/chat/reducerTimeline.ts | 1 + web/src/chat/types.ts | 7 + .../ToolMessage.displayLinks.test.tsx | 35 ++++- .../AssistantChat/messages/ToolMessage.tsx | 125 +++++++++++++----- web/src/lib/sessionExport/markdown.ts | 7 +- 20 files changed, 526 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7631fea1d3..1c298e64d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Start with the task's files; read only relevant sections of these references, no | Native API contract, chat conformance | [client contract](docs/api/client-contract/index.md), [iOS](ios/README.md), [Android](android/README.md) | | Encrypted native push relay | [relay/README.md](relay/README.md) | | User docs / marketing site | `docs/` (VitePress) / `website/` | -| Paint tappable URLs (Cursor only) | MCP `display_links` (Cursor sessions) or `hapi display-links` / `scripts/tooling/hapi-display-links.mjs` — construct hrefs by concatenation (`"tia"+"nn"`), never type landmine hosts in prose | +| Paint tappable URLs / exact-copy strings (Cursor only) | MCP `display_links` (Cursor sessions) or `hapi display-links` / `scripts/tooling/hapi-display-links.mjs` — construct hrefs and secrets by concatenation (`"tia"+"nn"`, `"VK"+"K"`), never type landmine hosts or tokens in prose; never echo secrets after painting the card | ## Repository conventions diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 5cc981874e..66d0e8d070 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -205,6 +205,27 @@ describe('startHappyServer skill_lookup', () => { expect(payload.urls[0]?.href).not.toContain('tian/hapi') }) + it('paints display_links exact-copy texts without echoing the value in the tool result', async () => { + const mcp = await connect(false, { enableDisplayLinks: true }) + const value = 'VK' + 'K' + + const result = await mcp.callTool({ + name: 'display_links', + arguments: { texts: [{ value, title: 'gate' }] } + }) as ToolResult + + expect(result.isError).toBe(false) + expect(result.content?.[0]?.text).toContain('exact-copy') + expect(result.content?.[0]?.text).not.toContain(value) + expect(sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'display-links', + texts: [{ value: 'VKK', title: 'gate' }], + })) + const payload = sendAgentMessage.mock.calls[0]?.[0] as { texts: Array<{ value: string }> } + expect(payload.texts[0]?.value).toBe(value) + expect(payload.texts[0]?.value).not.toBe('VK') + }) + it('rejects javascript hrefs without emitting an agent message', async () => { const mcp = await connect(false, { enableDisplayLinks: true }) const result = await mcp.callTool({ diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index c7e2d96895..fb1fd9d6d1 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -20,7 +20,7 @@ import { } from "@/modules/common/generatedImages"; import type { InlineMediaSource } from "@/modules/common/inlineMediaSource"; import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_LINKS_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from "@/modules/common/displayImagePrompt"; -import { buildDisplayLinksPayload, parseDisplayLinksInput } from "@hapi/protocol"; +import { buildDisplayLinksPayload, parseDisplayLinksToolInput } from "@hapi/protocol"; import { resolveSkill } from "@/modules/common/skills"; import { INSPECT_PEER_TOOL_DESCRIPTION, @@ -129,7 +129,14 @@ function createHapiMcpServer( title: z.string().trim().min(1).max(255).optional().describe('Optional link label shown on the card'), }), z.string(), - ])).min(1).max(20).describe('One or more http(s) URLs to paint as tappable cards'), + ])).min(1).max(20).optional().describe('Optional http(s) URLs to paint as tappable cards'), + texts: z.array(z.union([ + z.object({ + value: z.string().min(1).max(8192).describe('Exact string to paint for copy. Construct by concatenation (VK+K), never copy from model prose.'), + title: z.string().trim().min(1).max(255).optional().describe('Optional label shown on the copy card'), + }), + z.string().min(1).max(8192), + ])).min(1).max(20).optional().describe('Optional exact-copy strings (secrets, tokens, SHAs, tags, MagicDNS labels)'), }); const pingPeerInputSchema: z.ZodTypeAny = z.object({ @@ -317,24 +324,35 @@ function createHapiMcpServer( if (enableDisplayLinks) { mcp.registerTool('display_links', { - description: `Paint clickable http(s) URL cards into the current HAPI chat without a fake user turn. Cursor-only: other flavors type URLs fine. ${DISPLAY_LINKS_PROMPT_CURSOR}`, + description: `Paint clickable http(s) URL cards and/or exact-copy strings into the current HAPI chat without a fake user turn. Cursor-only: other flavors type URLs and secrets fine. ${DISPLAY_LINKS_PROMPT_CURSOR}`, title: 'Display Links', inputSchema: displayLinksInputSchema, - }, async (args: { urls: Array<{ href: string; title?: string } | string> }) => { - logger.debug('[hapiMCP] Display links:', args.urls); + }, async (args: { + urls?: Array<{ href: string; title?: string } | string> + texts?: Array<{ value: string; title?: string } | string> + }) => { + logger.debug('[hapiMCP] Display links: urls=', args.urls?.length ?? 0, 'texts=', args.texts?.length ?? 0); try { - const urls = parseDisplayLinksInput(args.urls); + const parsed = parseDisplayLinksToolInput(args); client.sendAgentMessage(buildDisplayLinksPayload({ - urls, + urls: parsed.urls, + texts: parsed.texts, id: randomUUID(), })); + const parts: string[] = []; + if (parsed.urls.length > 0) { + parts.push(`${parsed.urls.length} link${parsed.urls.length === 1 ? '' : 's'}`); + } + if (parsed.texts.length > 0) { + parts.push(`${parsed.texts.length} exact-copy card${parsed.texts.length === 1 ? '' : 's'}`); + } return { content: [ { type: 'text' as const, - text: `Displayed ${urls.length} link${urls.length === 1 ? '' : 's'}`, + text: `Displayed ${parts.join(', ')}`, }, ], isError: false, diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index 08c05906c2..9ff0bf2b7f 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -125,6 +125,26 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { }) }) + it('forwards display_links texts arguments unchanged', async () => { + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + 'display_links' + ]) + + const handler = harness.tools.get('display_links') + const value = 'VK' + 'K' + await expect(handler?.({ texts: [{ value, title: 'gate' }] })).resolves.toEqual({ + content: [{ type: 'text', text: 'forwarded' }], + isError: false + }) + expect(harness.callTool).toHaveBeenCalledWith({ + name: 'display_links', + arguments: { texts: [{ value, title: 'gate' }] } + }) + }) + it('forwards display_media arguments unchanged', async () => { await runHappyMcpStdioBridge([ '--url', diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index f379977e14..79e1245f52 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -209,14 +209,21 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { title: z.string().trim().min(1).max(255).optional().describe('Optional link label shown on the card'), }), z.string(), - ])).min(1).max(20).describe('One or more http(s) URLs to paint as tappable cards'), + ])).min(1).max(20).optional().describe('Optional http(s) URLs to paint as tappable cards'), + texts: z.array(z.union([ + z.object({ + value: z.string().min(1).max(8192).describe('Exact string to paint for copy. Construct by concatenation (VK+K), never copy from model prose.'), + title: z.string().trim().min(1).max(255).optional().describe('Optional label shown on the copy card'), + }), + z.string().min(1).max(8192), + ])).min(1).max(20).optional().describe('Optional exact-copy strings (secrets, tokens, SHAs, tags, MagicDNS labels)'), }); if (toolNames.has('display_links')) { server.registerTool( 'display_links', { - description: `Paint clickable http(s) URL cards into the current HAPI chat. ${DISPLAY_LINKS_PROMPT_CURSOR}`, + description: `Paint clickable http(s) URL cards and/or exact-copy strings into the current HAPI chat. ${DISPLAY_LINKS_PROMPT_CURSOR}`, title: 'Display Links', inputSchema: displayLinksInputSchema, }, diff --git a/cli/src/commands/displayLinks.test.ts b/cli/src/commands/displayLinks.test.ts index 465dace34b..67b52930fc 100644 --- a/cli/src/commands/displayLinks.test.ts +++ b/cli/src/commands/displayLinks.test.ts @@ -8,6 +8,7 @@ describe('parseDisplayLinksArgs', () => { help: false, sessionArg: null, href, + texts: [], title: 'Issue 1516', }) }) @@ -17,6 +18,7 @@ describe('parseDisplayLinksArgs', () => { help: false, sessionArg: 'abc12345', href: 'https://example.com', + texts: [], title: 'Example', }) }) @@ -26,6 +28,7 @@ describe('parseDisplayLinksArgs', () => { help: false, sessionArg: 'self', href: 'https://example.com', + texts: [], title: undefined, }) }) @@ -34,9 +37,31 @@ describe('parseDisplayLinksArgs', () => { expect(parseDisplayLinksArgs(['--help']).help).toBe(true) }) - it('throws when href is missing', () => { - expect(() => parseDisplayLinksArgs([])).toThrow(/missing href/) - expect(() => parseDisplayLinksArgs(['self'])).toThrow(/missing href/) + it('parses --text exact-copy value by concatenation', () => { + const value = 'VK' + 'K' + expect(parseDisplayLinksArgs(['--text', value, 'gate'])).toEqual({ + help: false, + sessionArg: null, + href: '', + texts: [{ value, title: 'gate' }], + title: undefined, + }) + }) + + it('parses self --text', () => { + const value = 'dead' + 'beef' + expect(parseDisplayLinksArgs(['self', '--text', value])).toEqual({ + help: false, + sessionArg: 'self', + href: '', + texts: [{ value }], + title: undefined, + }) + }) + + it('throws when href and --text are missing', () => { + expect(() => parseDisplayLinksArgs([])).toThrow(/missing href|--text/) + expect(() => parseDisplayLinksArgs(['self'])).toThrow(/missing href|--text/) }) }) diff --git a/cli/src/commands/displayLinks.ts b/cli/src/commands/displayLinks.ts index 54305e42a6..518911ba07 100644 --- a/cli/src/commands/displayLinks.ts +++ b/cli/src/commands/displayLinks.ts @@ -1,7 +1,7 @@ import chalk from 'chalk' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { parseDisplayLinksInput } from '@hapi/protocol' +import { parseDisplayLinksToolInput } from '@hapi/protocol' import { getAuthToken } from '@/api/auth' import { configuration } from '@/configuration' import { initializeToken } from '@/ui/tokenInit' @@ -33,52 +33,84 @@ type ParsedDisplayLinksArgs = { help: boolean sessionArg: string | null href: string + texts: Array<{ value: string; title?: string }> title?: string } export function parseDisplayLinksArgs(args: string[]): ParsedDisplayLinksArgs { if (args.includes('--help') || args.includes('-h')) { - return { help: true, sessionArg: null, href: '' } + return { help: true, sessionArg: null, href: '', texts: [] } } + + const textIndex = args.indexOf('--text') + if (textIndex >= 0) { + const value = args[textIndex + 1] + if (!value) { + throw new Error('missing --text value; usage: hapi display-links [self] --text [title]') + } + const before = args.slice(0, textIndex) + const after = args.slice(textIndex + 2) + if (before.length > 1) { + throw new Error('usage: hapi display-links [self] --text [title]') + } + const session = before[0] && !looksLikeHref(before[0]) ? before[0] : null + const title = after[0] + return { + help: false, + sessionArg: session, + href: '', + texts: title ? [{ value, title }] : [{ value }], + } + } + if (args.length === 0) { - throw new Error('missing href; usage: hapi display-links [|self] [title]') + throw new Error('missing href or --text; usage: hapi display-links [|self] [title]') } - const firstLooksLikeHref = /^https?:\/\//i.test(args[0] ?? '') + const firstLooksLikeHref = looksLikeHref(args[0] ?? '') if (firstLooksLikeHref) { return { help: false, sessionArg: null, href: args[0]!, + texts: [], title: args[1], } } if (args.length < 2) { - throw new Error('missing href; usage: hapi display-links [|self] [title]') + throw new Error('missing href or --text; usage: hapi display-links [|self] [title]') } return { help: false, sessionArg: args[0] ?? null, href: args[1]!, + texts: [], title: args[2], } } +function looksLikeHref(value: string): boolean { + return /^https?:\/\//i.test(value) +} + function showHelp(): void { console.log(` -${chalk.bold('hapi display-links')} - Paint tappable http(s) URL cards into a HAPI session +${chalk.bold('hapi display-links')} - Paint tappable URL / exact-copy cards into a HAPI session ${chalk.bold('Usage:')} hapi display-links [title] hapi display-links self [title] + hapi display-links --text [title] + hapi display-links self --text [title] ${chalk.bold('Notes:')} Uses this process's session MCP bridge (loopback hapiMcpUrl). Does not create a user turn. Other-session / cross-runner targeting is refused — run the command on the runner that owns the session. - Construct landmine hosts by concatenation in the calling script ("tia"+"nn"), never from model prose. - http/https only. javascript/data/vbscript/file are rejected. + Construct landmine hosts and exact strings by concatenation in the calling script ("tia"+"nn", "VK"+"K"), never from model prose. + urls are http/https only. javascript/data/vbscript/file are rejected. + Do not print secrets in assistant prose; the card is the copy path. ${chalk.bold('Env:')} HAPI_SESSION_ID (self-target), HAPI_API_URL / CLI_API_TOKEN @@ -174,9 +206,12 @@ export async function handleDisplayLinksCommand(args: string[]): Promise { return } - const urls = parseDisplayLinksInput( - parsed.title ? [{ href: parsed.href, title: parsed.title }] : [{ href: parsed.href }] - ) + const toolArgs = parseDisplayLinksToolInput({ + ...(parsed.href ? { + urls: parsed.title ? [{ href: parsed.href, title: parsed.title }] : [{ href: parsed.href }], + } : {}), + ...(parsed.texts.length > 0 ? { texts: parsed.texts } : {}), + }) assertLocalDisplayLinksTarget(parsed.sessionArg) @@ -200,7 +235,10 @@ export async function handleDisplayLinksCommand(args: string[]): Promise { try { const result = await client.callTool({ name: 'display_links', - arguments: { urls }, + arguments: { + ...(toolArgs.urls.length > 0 ? { urls: toolArgs.urls } : {}), + ...(toolArgs.texts.length > 0 ? { texts: toolArgs.texts } : {}), + }, }) console.log(JSON.stringify(result, null, 2)) } finally { diff --git a/cli/src/modules/common/displayImagePrompt.ts b/cli/src/modules/common/displayImagePrompt.ts index 6f068f5f89..12ade5ea37 100644 --- a/cli/src/modules/common/displayImagePrompt.ts +++ b/cli/src/modules/common/displayImagePrompt.ts @@ -57,5 +57,5 @@ export const DISPLAY_MEDIA_PROMPT_CURSOR = trimIdent(` `); export const DISPLAY_LINKS_PROMPT_CURSOR = trimIdent(` - When the operator needs a tappable URL in this HAPI chat, call the tool "display_links" with { urls: [{ href, title? }] }. Do not type URLs in assistant prose — Cursor-routed models drop doubled letters when recalling hosts (tiann→tian, MagicDNS labels) and headset operators tap a 404. Construct hrefs by concatenation in the tool arguments ("tia"+"nn"), never copy a URL from your own text. http/https only. + When the operator needs a tappable URL or a byte-accurate copyable string in this HAPI chat, call the tool "display_links" with { urls: [{ href, title? }], texts: [{ value, title? }] } (at least one of urls or texts). Do not type URLs, secrets, tokens, SHAs, tags, or MagicDNS labels in assistant prose — Cursor-routed models drop doubled letters (tiann→tian, VKK→VK) and headset operators paste a 403/404. Construct values by concatenation in the tool arguments ("tia"+"nn", "VK"+"K"), never copy from your own text. Never echo secrets in assistant prose after painting the card. urls are http/https only. `); diff --git a/scripts/tooling/hapi-display-links.mjs b/scripts/tooling/hapi-display-links.mjs index 24ce55de94..8d2e369b77 100644 --- a/scripts/tooling/hapi-display-links.mjs +++ b/scripts/tooling/hapi-display-links.mjs @@ -59,13 +59,28 @@ function looksLikeHref(value) { } // Arg shapes: -// [title] → self-target current session -// [title] → self-target, explicit +// [title] +// [title] +// --text [title] +// --text [title] const args = process.argv.slice(2) let sessionArg let href let title -if (args.length > 0 && looksLikeHref(args[0]) && !SELF_TOKENS.has(args[0])) { +let texts +const textIndex = args.indexOf('--text') +if (textIndex >= 0) { + const value = args[textIndex + 1] + if (!value) { + console.error('usage: hapi-display-links.mjs [self] --text [title]') + process.exit(2) + } + const before = args.slice(0, textIndex) + sessionArg = before[0] && !looksLikeHref(before[0]) ? before[0] : null + href = null + title = args[textIndex + 2] + texts = title ? [{ value, title }] : [{ value }] +} else if (args.length > 0 && looksLikeHref(args[0]) && !SELF_TOKENS.has(args[0])) { sessionArg = null href = args[0] title = args[1] @@ -75,8 +90,9 @@ if (args.length > 0 && looksLikeHref(args[0]) && !SELF_TOKENS.has(args[0])) { title = args[2] } -if (!href) { +if (!href && !(texts && texts.length > 0)) { console.error('usage: hapi-display-links.mjs [self] [title]') + console.error(' or: hapi-display-links.mjs [self] --text [title]') console.error(' or: HAPI_SESSION_ID= hapi-display-links.mjs [title]') process.exit(2) } @@ -175,7 +191,10 @@ const transport = new StreamableHTTPClientTransport(new URL(mcpUrl)) await client.connect(transport) const result = await client.callTool({ name: 'display_links', - arguments: { urls: title ? [{ href, title }] : [{ href }] }, + arguments: { + ...(href ? { urls: title ? [{ href, title }] : [{ href }] } : {}), + ...(texts && texts.length > 0 ? { texts } : {}), + }, }) await client.close() console.log(JSON.stringify(result, null, 2)) diff --git a/shared/src/displayLinks.test.ts b/shared/src/displayLinks.test.ts index 0bd5ab62fe..efad4a8936 100644 --- a/shared/src/displayLinks.test.ts +++ b/shared/src/displayLinks.test.ts @@ -3,7 +3,10 @@ import { buildDisplayLinksPayload, isDisplayableHttpHref, parseDisplayLinksInput, + parseDisplayLinksToolInput, + parseDisplayTextsInput, safeParseDisplayLinksInput, + safeParseDisplayTextsInput, } from './displayLinks' describe('isDisplayableHttpHref', () => { @@ -82,6 +85,63 @@ describe('safeParseDisplayLinksInput', () => { }) }) +describe('parseDisplayTextsInput', () => { + it('round-trips a concatenated doubled-letter secret as stored bytes', () => { + const value = 'VK' + 'K' + const texts = parseDisplayTextsInput([{ value, title: 'gate' }]) + expect(texts).toEqual([{ value: 'VKK', title: 'gate' }]) + expect(texts[0]?.value).toBe(value) + expect(texts[0]?.value).not.toBe('VK') + }) + + it('accepts a bare string in the texts array', () => { + const value = 'abc' + 'c' + 'def' + expect(parseDisplayTextsInput([value])).toEqual([{ value }]) + }) + + it('throws when texts is missing or empty', () => { + expect(() => parseDisplayTextsInput(undefined)).toThrow(/texts/) + expect(() => parseDisplayTextsInput([])).toThrow(/at least one/) + }) + + it('throws on empty values instead of storing blanks', () => { + expect(() => parseDisplayTextsInput([{ value: ' ' }])).toThrow(/rejected/) + }) +}) + +describe('safeParseDisplayTextsInput', () => { + it('drops empty entries instead of throwing (untrusted stored payloads)', () => { + const value = 'VK' + 'K' + expect(safeParseDisplayTextsInput([ + { value: '' }, + { value }, + { value: ' ' }, + ])).toEqual([{ value }]) + }) +}) + +describe('parseDisplayLinksToolInput', () => { + it('accepts texts without urls', () => { + const value = 'dead' + 'beef' + expect(parseDisplayLinksToolInput({ texts: [{ value, title: 'sha' }] })).toEqual({ + urls: [], + texts: [{ value, title: 'sha' }], + }) + }) + + it('accepts urls without texts', () => { + const href = 'https://example.com/a' + expect(parseDisplayLinksToolInput({ urls: [{ href }] })).toEqual({ + urls: [{ href }], + texts: [], + }) + }) + + it('throws when both urls and texts are missing', () => { + expect(() => parseDisplayLinksToolInput({})).toThrow(/urls|texts/) + }) +}) + describe('buildDisplayLinksPayload', () => { it('stores caller href bytes on the wire payload', () => { const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' @@ -94,4 +154,16 @@ describe('buildDisplayLinksPayload', () => { expect(JSON.stringify(payload)).toContain('tiann/hapi') expect(JSON.stringify(payload)).not.toContain('tian/hapi') }) + + it('stores concatenated exact-copy bytes without echoing a mangled sibling', () => { + const value = 'VK' + 'K' + const payload = buildDisplayLinksPayload({ + urls: [], + texts: [{ value, title: 'gate' }], + id: 'text-1', + }) + expect(payload.texts[0]?.value).toBe(value) + expect(JSON.stringify(payload)).toContain('VKK') + expect(JSON.stringify(payload)).not.toMatch(/"VK"/) + }) }) diff --git a/shared/src/displayLinks.ts b/shared/src/displayLinks.ts index c923a77ee6..ce1c4d0134 100644 --- a/shared/src/displayLinks.ts +++ b/shared/src/displayLinks.ts @@ -10,6 +10,7 @@ export const DISPLAY_LINKS_PAYLOAD_TYPE = 'display-links' as const export const MAX_DISPLAY_LINKS = 20 export const MAX_DISPLAY_LINK_HREF_LENGTH = 2048 export const MAX_DISPLAY_LINK_TITLE_LENGTH = 255 +export const MAX_DISPLAY_TEXT_LENGTH = 8192 const DENY_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file']) @@ -18,9 +19,15 @@ export type DisplayLink = { title?: string } +export type DisplayText = { + value: string + title?: string +} + export type DisplayLinksPayload = { type: typeof DISPLAY_LINKS_PAYLOAD_TYPE urls: DisplayLink[] + texts: DisplayText[] id: string } @@ -87,6 +94,71 @@ export function parseDisplayLink(input: unknown): DisplayLink | null { return title ? { href, title } : { href } } +export function parseDisplayText(input: unknown): DisplayText | null { + if (typeof input === 'string') { + if (input.length === 0 || input.length > MAX_DISPLAY_TEXT_LENGTH) return null + if (input.trim().length === 0) return null + return { value: input } + } + if (!input || typeof input !== 'object') return null + const record = input as Record + const rawValue = record.value ?? record.text + if (typeof rawValue !== 'string') return null + if (rawValue.length === 0 || rawValue.length > MAX_DISPLAY_TEXT_LENGTH) return null + if (rawValue.trim().length === 0) return null + const title = normalizeTitle(record.title) + return title ? { value: rawValue, title } : { value: rawValue } +} + +export function parseDisplayTextsInput(input: unknown): DisplayText[] { + if (!Array.isArray(input)) { + throw new Error('display_links requires texts: [{ value, title? }]') + } + if (input.length === 0) { + throw new Error('display_links requires at least one exact-copy string') + } + if (input.length > MAX_DISPLAY_LINKS) { + throw new Error(`display_links accepts at most ${MAX_DISPLAY_LINKS} exact-copy strings`) + } + const texts: DisplayText[] = [] + for (const item of input) { + const parsed = parseDisplayText(item) + if (!parsed) { + throw new Error('display_links rejected an exact-copy string (empty or too long)') + } + texts.push(parsed) + } + return texts +} + +export function safeParseDisplayTextsInput(input: unknown): DisplayText[] { + if (!Array.isArray(input)) return [] + const texts: DisplayText[] = [] + for (const item of input.slice(0, MAX_DISPLAY_LINKS)) { + const parsed = parseDisplayText(item) + if (parsed) texts.push(parsed) + } + return texts +} + +export function parseDisplayLinksToolInput(input: unknown): { + urls: DisplayLink[] + texts: DisplayText[] +} { + const record = input && typeof input === 'object' ? input as Record : {} + const hasUrls = Array.isArray(record.urls) + const hasTexts = Array.isArray(record.texts) + if (!hasUrls && !hasTexts) { + throw new Error('display_links requires urls: [{ href, title? }] and/or texts: [{ value, title? }]') + } + const urls = hasUrls ? parseDisplayLinksInput(record.urls) : [] + const texts = hasTexts ? parseDisplayTextsInput(record.texts) : [] + if (urls.length === 0 && texts.length === 0) { + throw new Error('display_links requires urls: [{ href, title? }] and/or texts: [{ value, title? }]') + } + return { urls, texts } +} + export function parseDisplayLinksInput(input: unknown): DisplayLink[] { if (!Array.isArray(input)) { throw new Error('display_links requires urls: [{ href, title? }]') @@ -120,11 +192,13 @@ export function safeParseDisplayLinksInput(input: unknown): DisplayLink[] { export function buildDisplayLinksPayload(args: { urls: DisplayLink[] + texts?: DisplayText[] id: string }): DisplayLinksPayload { return { type: DISPLAY_LINKS_PAYLOAD_TYPE, urls: args.urls, + texts: args.texts ?? [], id: args.id, } } diff --git a/web/src/chat/normalizeAgent.test.ts b/web/src/chat/normalizeAgent.test.ts index 62fc5d5c2c..f941ffa0c6 100644 --- a/web/src/chat/normalizeAgent.test.ts +++ b/web/src/chat/normalizeAgent.test.ts @@ -307,6 +307,31 @@ describe('normalizeAgentRecord — display-links', () => { expect(urls[0] && urls[0].type === 'display-links' ? urls[0].urls[0]?.href : null).toBe(href) }) + it('round-trips concatenated exact-copy bytes as agent content', () => { + const value = 'VK' + 'K' + const normalized = normalizeAgentRecord('msg-text', null, 1, { + type: 'codex', + data: { + type: 'display-links', + id: 'text-1', + texts: [{ value, title: 'gate' }] + } + }) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [{ + type: 'display-links', + urls: [], + texts: [{ value: 'VKK', title: 'gate' }] + }] + }) + const blocks = normalized && normalized.role === 'agent' + ? normalized.content.filter((c) => c.type === 'display-links') + : [] + expect(blocks[0] && blocks[0].type === 'display-links' ? blocks[0].texts[0]?.value : null).toBe(value) + }) + it('drops display-links payloads that contain only denied schemes', () => { const normalized = normalizeAgentRecord('msg-evil', null, 1, { type: 'codex', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 2976bb125a..ea78222e9b 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -1,6 +1,6 @@ import type { AgentEvent, CodexReview, CodexReviewFinding, NormalizedAgentContent, NormalizedMessage, RoundModelUsage, RoundSummary, ToolResultPermission, UsageData } from '@/chat/types' import { inlineMediaSourceFromWire } from '@/chat/inlineMediaSource' -import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject, safeParseDisplayLinksInput } from '@hapi/protocol' +import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject, safeParseDisplayLinksInput, safeParseDisplayTextsInput } from '@hapi/protocol' import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages' import { parseAgentTimestampMs } from '@/chat/agentTimestamp' @@ -1018,7 +1018,8 @@ export function normalizeAgentRecord( if (data.type === 'display-links') { const urls = safeParseDisplayLinksInput(data.urls) - if (urls.length === 0) return null + const texts = safeParseDisplayTextsInput(data.texts) + if (urls.length === 0 && texts.length === 0) return null const uuid = asString(data.id) ?? messageId return { id: messageId, @@ -1029,6 +1030,7 @@ export function normalizeAgentRecord( content: [{ type: 'display-links', urls, + texts, uuid, parentUUID: null, }], diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 5ab0fd06cf..89f46ffca4 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -201,6 +201,14 @@ function areDisplayLinksBlocksEqual(left: DisplayLinksBlock, right: DisplayLinks return false } } + if ((left.texts?.length ?? 0) !== (right.texts?.length ?? 0)) return false + const leftTexts = left.texts ?? [] + const rightTexts = right.texts ?? [] + for (let i = 0; i < leftTexts.length; i += 1) { + if (leftTexts[i]?.value !== rightTexts[i]?.value || leftTexts[i]?.title !== rightTexts[i]?.title) { + return false + } + } return true } diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts index 7e3c7b80d6..10ee1531bf 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -1626,6 +1626,7 @@ describe('reduceTimeline', () => { content: [{ type: 'display-links', urls: [{ href, title: 'Issue 1516' }], + texts: [], uuid: 'u-links', parentUUID: null }], @@ -1640,4 +1641,29 @@ describe('reduceTimeline', () => { }) expect(blocks[0].kind).not.toBe('user-text') }) + + it('renders display-links exact-copy texts on the timeline block', () => { + const value = 'VK' + 'K' + const { blocks } = reduceTimeline([ + { + id: 'msg-text', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ + type: 'display-links', + urls: [], + texts: [{ value, title: 'gate' }], + uuid: 'u-text', + parentUUID: null + }], + isSidechain: false + } as TracedMessage + ], makeContext()) + + expect(blocks[0]).toMatchObject({ + kind: 'display-links', + texts: [{ value: 'VKK', title: 'gate' }] + }) + }) }) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 3e13d9670e..61d8e42a65 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -909,6 +909,7 @@ export function reduceTimeline( createdAt: msg.createdAt, invokedAt: msg.invokedAt, urls: c.urls, + texts: c.texts ?? [], meta: msg.meta }) continue diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 1d63c0744a..a904f67230 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -99,9 +99,15 @@ export type DisplayLinkItem = { title?: string } +export type DisplayTextItem = { + value: string + title?: string +} + export type DisplayLinksContent = { type: 'display-links' urls: DisplayLinkItem[] + texts: DisplayTextItem[] uuid: string parentUUID: string | null } @@ -322,6 +328,7 @@ export type DisplayLinksBlock = { createdAt: number invokedAt?: number | null urls: DisplayLinkItem[] + texts?: DisplayTextItem[] meta?: unknown } diff --git a/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx index 63bbfd64bd..fd96406101 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DisplayLinksCard } from '@/components/AssistantChat/messages/ToolMessage' describe('DisplayLinksCard', () => { @@ -25,6 +25,37 @@ describe('DisplayLinksCard', () => { expect(link).toHaveAttribute('rel', 'noopener noreferrer') }) + it('paints concatenated exact-copy bytes on a copy control, not as a link', async () => { + const value = 'VK' + 'K' + const writeText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { clipboard: { writeText } }) + + render( + + ) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + const copyButton = screen.getByRole('button', { name: /copy gate/i }) + expect(copyButton).toHaveAttribute('data-copy-value', 'VKK') + expect(copyButton.getAttribute('data-copy-value')).toBe(value) + expect(copyButton.getAttribute('data-copy-value')).not.toBe('VK') + expect(screen.getByTestId('display-links-text')).toHaveTextContent('VKK') + + fireEvent.click(copyButton) + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(value) + }) + }) + it('does not make javascript hrefs tappable', () => { render( { + try { + await safeCopyToClipboard(props.value) + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } catch { + // Clipboard unavailable; the exact bytes remain visible to copy manually. + } + }, [props.value]) + + return ( +
  • + +
  • + ) +} + /** Exported for display-links renderer tests. */ export function DisplayLinksCard(props: { block: DisplayLinksBlock }) { + const texts = props.block.texts ?? [] + const showLinks = props.block.urls.length > 0 + const showTexts = texts.length > 0 return (
    - Links + {showLinks && showTexts ? 'Links and exact copy' : showTexts ? 'Exact copy' : 'Links'}
    -
      - {props.block.urls.map((url) => { - const navigable = isDisplayableHttpHref(url.href) - const label = url.title?.trim() || url.href - if (!navigable) { + {showLinks ? ( +
        + {props.block.urls.map((url) => { + const navigable = isDisplayableHttpHref(url.href) + const label = url.title?.trim() || url.href + if (!navigable) { + return ( +
      • + + {label} + +
      • + ) + } return (
      • - - {label} - + {label} + {url.title ? ( + + {url.href} + + ) : null} +
      • ) - } - return ( -
      • - - {label} - {url.title ? ( - - {url.href} - - ) : null} - -
      • - ) - })} -
      + })} +
    + ) : null} + {showTexts ? ( +
      + {texts.map((text, index) => ( + + ))} +
    + ) : null}
    ) } diff --git a/web/src/lib/sessionExport/markdown.ts b/web/src/lib/sessionExport/markdown.ts index 851d585aab..e56235cb3c 100644 --- a/web/src/lib/sessionExport/markdown.ts +++ b/web/src/lib/sessionExport/markdown.ts @@ -93,8 +93,11 @@ function formatAgentContentBlock(block: NormalizedAgentContent): string | null { } case 'generated-image': return `- Generated image: ${block.fileName}` - case 'display-links': - return block.urls.map((url) => `- Link: ${url.title ? `${url.title} (${url.href})` : url.href}`).join('\n') + case 'display-links': { + const links = block.urls.map((url) => `- Link: ${url.title ? `${url.title} (${url.href})` : url.href}`) + const texts = (block.texts ?? []).map((text) => `- Copy: ${text.title ? `${text.title} (${text.value})` : text.value}`) + return [...links, ...texts].join('\n') || null + } case 'codex-review': return `- Codex review: ${block.review.overallCorrectness ?? 'review'} (${block.review.findings.length} findings)` case 'summary': From 150a91fc7b499251527666605ae5f29e17e60db0 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:14:37 +0000 Subject: [PATCH 06/26] fix(display-links): unique row keys and localize card labels Duplicate hrefs with different titles collided on React keys. Card chrome was English-only in a file that already uses i18n. Co-authored-by: Cursor --- .../ToolMessage.displayLinks.test.tsx | 87 +++++++++++-------- .../AssistantChat/messages/ToolMessage.tsx | 22 +++-- web/src/lib/locales/en.ts | 7 ++ web/src/lib/locales/zh-CN.ts | 7 ++ 4 files changed, 82 insertions(+), 41 deletions(-) diff --git a/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx index fd96406101..6444a57101 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx @@ -1,21 +1,27 @@ import { describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' import { DisplayLinksCard } from '@/components/AssistantChat/messages/ToolMessage' +import type { DisplayLinksBlock } from '@/chat/types' + +function renderCard(block: DisplayLinksBlock) { + return render( + + + + ) +} describe('DisplayLinksCard', () => { it('paints the constructed href without reconstructing from prose', () => { const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516' - render( - - ) + renderCard({ + kind: 'display-links', + id: 'block-1', + localId: null, + createdAt: 1, + urls: [{ href, title: 'Issue 1516' }], + }) const link = screen.getByRole('link', { name: /Issue 1516/ }) expect(link).toHaveAttribute('href', 'https://github.com/tiann/hapi/issues/1516') @@ -25,23 +31,40 @@ describe('DisplayLinksCard', () => { expect(link).toHaveAttribute('rel', 'noopener noreferrer') }) + it('uses unique keys when two cards share an href with different titles', () => { + const href = 'https://example.com/same' + renderCard({ + kind: 'display-links', + id: 'block-dup', + localId: null, + createdAt: 1, + urls: [ + { href, title: 'First' }, + { href, title: 'Second' }, + ], + }) + + const links = screen.getAllByRole('link') + expect(links).toHaveLength(2) + expect(links[0]).toHaveAttribute('href', href) + expect(links[1]).toHaveAttribute('href', href) + expect(screen.getByRole('link', { name: /First/ })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Second/ })).toBeInTheDocument() + }) + it('paints concatenated exact-copy bytes on a copy control, not as a link', async () => { const value = 'VK' + 'K' const writeText = vi.fn().mockResolvedValue(undefined) Object.assign(navigator, { clipboard: { writeText } }) - render( - - ) + renderCard({ + kind: 'display-links', + id: 'block-text', + localId: null, + createdAt: 1, + urls: [], + texts: [{ value, title: 'gate' }], + }) expect(screen.queryByRole('link')).not.toBeInTheDocument() const copyButton = screen.getByRole('button', { name: /copy gate/i }) @@ -57,17 +80,13 @@ describe('DisplayLinksCard', () => { }) it('does not make javascript hrefs tappable', () => { - render( - - ) + renderCard({ + kind: 'display-links', + id: 'block-evil', + localId: null, + createdAt: 1, + urls: [{ href: 'javascript:alert(1)', title: 'evil' }], + }) expect(screen.queryByRole('link')).not.toBeInTheDocument() expect(screen.getByText('evil')).toBeInTheDocument() diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index d0060eeb9a..e3c2b2314b 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -63,8 +63,9 @@ function isDisplayLinksBlock(value: unknown): value is DisplayLinksBlock { } function DisplayLinksCopyRow(props: { value: string; title?: string }) { + const { t } = useTranslation() const [copied, setCopied] = useState(false) - const label = props.title?.trim() || 'Copy exact string' + const label = props.title?.trim() || t('displayLinks.exactString') const handleCopy = useCallback(async () => { try { await safeCopyToClipboard(props.value) @@ -82,7 +83,7 @@ function DisplayLinksCopyRow(props: { value: string; title?: string }) { onClick={() => { void handleCopy() }} data-testid="display-links-text" data-copy-value={props.value} - aria-label={`Copy ${label}`} + aria-label={t('displayLinks.copyAria', { label })} className="block w-full rounded-xl bg-[var(--app-subtle-bg)] px-3 py-2 text-left" > {props.title ? ( @@ -94,7 +95,7 @@ function DisplayLinksCopyRow(props: { value: string; title?: string }) { {props.value} - {copied ? 'Copied' : 'Tap to copy'} + {copied ? t('displayLinks.copied') : t('displayLinks.tapToCopy')} @@ -103,25 +104,32 @@ function DisplayLinksCopyRow(props: { value: string; title?: string }) { /** Exported for display-links renderer tests. */ export function DisplayLinksCard(props: { block: DisplayLinksBlock }) { + const { t } = useTranslation() const texts = props.block.texts ?? [] const showLinks = props.block.urls.length > 0 const showTexts = texts.length > 0 + const heading = showLinks && showTexts + ? t('displayLinks.heading.both') + : showTexts + ? t('displayLinks.heading.texts') + : t('displayLinks.heading.links') return (
    - {showLinks && showTexts ? 'Links and exact copy' : showTexts ? 'Exact copy' : 'Links'} + {heading}
    {showLinks ? (
    ', + '', + ].join('') + + render( + + {}} + /> + + ) + + await waitFor(() => { + expect(document.body.textContent).toContain('Public issue') + }) + expect(document.body.textContent).not.toContain(secret) + }) +}) diff --git a/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx index 6444a57101..0add022e9a 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx @@ -72,6 +72,7 @@ describe('DisplayLinksCard', () => { expect(copyButton.getAttribute('data-copy-value')).toBe(value) expect(copyButton.getAttribute('data-copy-value')).not.toBe('VK') expect(screen.getByTestId('display-links-text')).toHaveTextContent('VKK') + expect(copyButton.closest('li')).toHaveAttribute('data-hapi-share-exclude', 'true') fireEvent.click(copyButton) await waitFor(() => { diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index e3c2b2314b..24642e13d9 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -77,7 +77,7 @@ function DisplayLinksCopyRow(props: { value: string; title?: string }) { }, [props.value]) return ( -
  • +