diff --git a/AGENTS.md b/AGENTS.md index dd7eb586d1..f19c49531c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,6 +139,7 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr | Modify message handling | `hub/src/sync/messageService.ts` | | Add notification type | `hub/src/notifications/` | | Add shared type | `shared/src/types.ts`, `shared/src/schemas.ts` | +| 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 | ## Important patterns diff --git a/cli/src/agent/hapiSessionEnv.ts b/cli/src/agent/hapiSessionEnv.ts index c46248d88e..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` tool for inline media when it is 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 fe1886578a..0c0277c6db 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -157,6 +157,7 @@ describe('runAgentSession', () => { await running expect(harness.startHappyServerOptions).toEqual({ + enableDisplayLinks: false, skillLookup: { workingDirectory: '/tmp/project', flavor: 'acp' 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 d7f0e70ad0..8418eed44f 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( @@ -116,6 +120,7 @@ describe('startHappyServer skill_lookup', () => { 'inspect_peer', 'list_peers' ]) + expect(tools.tools.map((tool) => tool.name)).not.toContain('display_links') }) it('displays audio through display_media and emits a generated media message', async () => { @@ -138,6 +143,49 @@ 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, { enableDisplayLinks: true }) + 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, { enableDisplayLinks: true }) + 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(), diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 6060bdc222..5787d0cc24 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, @@ -31,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', @@ -61,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); @@ -108,6 +122,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 +315,46 @@ 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}`, + 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', @@ -475,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) => { @@ -534,8 +599,12 @@ 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_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/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index 48d1c6a2f9..7ab992144d 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -53,7 +53,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([ @@ -61,6 +61,7 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { 'display_image', 'display_video', 'display_media', + 'display_links', 'skill_lookup' ]) @@ -87,6 +88,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 242fd95fb0..1c8b974498 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -2,6 +2,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`. + * `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. * @@ -16,7 +17,7 @@ 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, @@ -201,6 +202,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..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', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'] - : ['change_title', 'display_image', 'display_video', 'display_media', '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([ @@ -81,6 +89,7 @@ describe('buildHapiMcpBridge skill lookup config', () => { 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 () => { @@ -94,6 +103,16 @@ describe('buildHapiMcpBridge skill lookup config', () => { display_media: { approval_mode: 'prompt' }, 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 f92621f7a5..206c91acab 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -46,6 +46,8 @@ export interface HapiMcpBridge { export interface HapiMcpBridgeOptions { emitTitleSummary?: boolean; enableChangeTitle?: boolean; + /** Cursor-only (#1516). Also inferred from skillLookup.flavor === 'cursor'. */ + enableDisplayLinks?: boolean; skillLookup?: { workingDirectory: string; flavor: string; @@ -80,6 +82,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,6 +103,13 @@ export async function buildHapiMcpBridge( approval_mode: 'prompt' } }; + // 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/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 8bab771ea9..73cea559b2 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -19,6 +19,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 @@ -58,7 +59,8 @@ const COMMANDS: CommandDefinition[] = [ runnerCommand, notifyCommand, pingPeerCommand, - inspectPeerCommand + inspectPeerCommand, + displayLinksCommand ] const commandMap = new Map() diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 0e49a7b0c3..d9c02e1eec 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -85,6 +85,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/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 = { diff --git a/cli/src/modules/common/displayImagePrompt.ts b/cli/src/modules/common/displayImagePrompt.ts index 5445a3f262..6f5bc6f16a 100644 --- a/cli/src/modules/common/displayImagePrompt.ts +++ b/cli/src/modules/common/displayImagePrompt.ts @@ -51,3 +51,7 @@ 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_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/ui/doctorInlineMedia.ts b/cli/src/ui/doctorInlineMedia.ts index 5f3cb82a07..77424ccf39 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 (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 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) { 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 824e0d55c6..1ef9d30ba4 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -15,6 +15,7 @@ export * from './runnerCapabilities' export * from './socket' export * from './sessionSummary' export * from './sessionCitation' +export * from './displayLinks' export * from './sessionExport' export * from './piThinkingLevel' export * from './runnerCapabilities' diff --git a/web/src/chat/normalizeAgent.test.ts b/web/src/chat/normalizeAgent.test.ts index 5bb00d1bd1..beb2e35229 100644 --- a/web/src/chat/normalizeAgent.test.ts +++ b/web/src/chat/normalizeAgent.test.ts @@ -209,3 +209,41 @@ describe('normalizeAgentRecord — agentTimestamp exposure', () => { expect(normalized).toMatchObject({ role: 'agent', agentTimestamp: null }) }) }) + +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 d65afba4e2..fbb3b1f521 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -1,6 +1,6 @@ import type { AgentEvent, CodexReview, CodexReviewFinding, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } 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' @@ -933,6 +933,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 0869c37901..b9a1ba5181 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, @@ -148,6 +149,19 @@ function areGeneratedImageBlocksEqual(left: GeneratedImageBlock, right: Generate && left.meta === right.meta } +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 @@ -240,6 +254,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 3deb1fe0ad..c0a8f52e0a 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -1501,4 +1501,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 9cb12a9cc8..bcc9e71b6b 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -814,6 +814,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 = asString(c.streamId) if (streamId) { diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 08cee34f13..b83db73c84 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -74,6 +74,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 @@ -109,6 +121,7 @@ export type NormalizedAgentContent = | ToolUse | ToolResult | GeneratedImageContent + | DisplayLinksContent | { type: 'codex-review' review: CodexReview @@ -273,6 +286,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 @@ -297,4 +320,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 22f5acdb39..1bb599187e 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' @@ -51,6 +51,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. */ @@ -270,6 +329,14 @@ function HappyNestedBlockList(props: { ) } + if (block.kind === 'display-links') { + return ( +
+ +
+ ) + } + if (block.kind === 'agent-event') { const presentation = getEventPresentation(block.event) return ( @@ -358,6 +425,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 98c29e60a1..67c07807e9 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -484,6 +484,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':