diff --git a/AGENTS.md b/AGENTS.md index 5d49368812..ebb4b8e9e8 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 / exact-copy strings (Cursor only) | MCP `display_links` (Cursor sessions) or `hapi display-links` / `scripts/tooling/hapi-display-links.mjs` — pass `sessionId` = this chat; 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/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatBlockCard.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatBlockCard.kt index 37cb01b014..c462e0d1bc 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatBlockCard.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatBlockCard.kt @@ -8,6 +8,7 @@ import app.hapi.companion.feature.chat.blocks.AgentReasoningBlockView import app.hapi.companion.feature.chat.blocks.AgentTextBlockView import app.hapi.companion.feature.chat.blocks.CliOutputBlockView import app.hapi.companion.feature.chat.blocks.CodexReviewBlockView +import app.hapi.companion.feature.chat.blocks.DisplayLinksBlockView import app.hapi.companion.feature.chat.blocks.GeneratedImageBlockView import app.hapi.companion.feature.chat.blocks.ToolCallBlockView import app.hapi.companion.feature.chat.blocks.ToolGroupBlockView @@ -18,6 +19,7 @@ import app.hapi.protocol.chat.AgentTextBlock import app.hapi.protocol.chat.ChatBlock import app.hapi.protocol.chat.CliOutputBlock import app.hapi.protocol.chat.CodexReviewBlock +import app.hapi.protocol.chat.DisplayLinksBlock import app.hapi.protocol.chat.GeneratedImageBlock import app.hapi.protocol.chat.ToolCallBlock import app.hapi.protocol.chat.ToolGroupBlock @@ -71,6 +73,7 @@ fun ChatBlockCard( is AgentEventBlock -> AgentEventBlockView(block, modifier) is CliOutputBlock -> CliOutputBlockView(block, modifier) is GeneratedImageBlock -> GeneratedImageBlockView(block, modifier) + is DisplayLinksBlock -> DisplayLinksBlockView(block, modifier) is CodexReviewBlock -> CodexReviewBlockView(block, modifier) is ToolCallBlock -> ToolCallBlockView(block, basePath, modifier, processSteps) is ToolGroupBlock -> ToolGroupBlockView(block, basePath, modifier) diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatLinkHandler.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatLinkHandler.kt index a54ae76d01..2e69a54086 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatLinkHandler.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatLinkHandler.kt @@ -68,7 +68,7 @@ fun rememberChatLinkHandler( } } -private fun Context.openUrl(url: String) { +internal fun Context.openUrl(url: String) { try { startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } catch (_: ActivityNotFoundException) { diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/DisplayLinksBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/DisplayLinksBlockView.kt new file mode 100644 index 0000000000..bcfdef229e --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/DisplayLinksBlockView.kt @@ -0,0 +1,141 @@ +package app.hapi.companion.feature.chat.blocks + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.PersistableBundle +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import app.hapi.companion.feature.chat.openUrl +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.DisplayLinksBlock + +/** + * Port of web `DisplayLinksCard`: tappable http(s) rows plus exact-copy strings. + */ +@Composable +fun DisplayLinksBlockView(block: DisplayLinksBlock, modifier: Modifier = Modifier) { + val context = LocalContext.current + val hasUrls = block.urls.isNotEmpty() + val hasTexts = block.texts.isNotEmpty() + val heading = when { + hasUrls && hasTexts -> "Links & copy" + hasTexts -> "Copy" + else -> "Links" + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = heading, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + ) + for (url in block.urls) { + val urlTitle = url.title + val navigable = runCatching { + // Android Uri.parse is lenient; still mirror http(s) gate used by protocol. + val forScheme = url.href.replace(" ", "%20").toUri() + forScheme.scheme.equals("http", ignoreCase = true) || + forScheme.scheme.equals("https", ignoreCase = true) + }.getOrDefault(false) + if (navigable) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + // Prefer space-escaped href for ACTION_VIEW; keep card text original. + val openHref = if (url.href.contains(' ')) { + url.href.replace(" ", "%20") + } else { + url.href + } + context.openUrl(openHref) + } + .padding(10.dp), + ) { + Text( + text = urlTitle?.takeIf { it.isNotBlank() } ?: url.href, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!urlTitle.isNullOrBlank()) { + Text( + text = url.href, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } else { + Text( + text = urlTitle?.takeIf { it.isNotBlank() } ?: url.href, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.hapi.hint, + ) + } + } + for (text in block.texts) { + val textTitle = text.title + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(textTitle ?: "copy", text.value).apply { + description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + clipboard.setPrimaryClip(clip) + } + .padding(10.dp), + ) { + if (!textTitle.isNullOrBlank()) { + Text( + text = textTitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + ) + } + Text( + text = text.value, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Tap to copy", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + ) + } + } + } + } +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/ChatTypes.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/ChatTypes.kt index e6ab6266a7..3dc8a2b10b 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/ChatTypes.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/ChatTypes.kt @@ -200,6 +200,23 @@ sealed class NormalizedAgentContent { val source: InlineMediaSource? = null, ) : NormalizedAgentContent() + data class DisplayLinkItem( + val href: String, + val title: String? = null, + ) + + data class DisplayTextItem( + val value: String, + val title: String? = null, + ) + + data class DisplayLinks( + val urls: List, + val texts: List = emptyList(), + val uuid: String, + val parentUUID: String? = null, + ) : NormalizedAgentContent() + data class CodexReviewContent( val review: CodexReview, val uuid: String, @@ -221,6 +238,7 @@ internal fun NormalizedAgentContent.uuidOrNull(): String? = when (this) { is NormalizedAgentContent.ToolUse -> uuid is NormalizedAgentContent.ToolResult -> uuid is NormalizedAgentContent.GeneratedImage -> uuid + is NormalizedAgentContent.DisplayLinks -> uuid is NormalizedAgentContent.CodexReviewContent -> uuid is NormalizedAgentContent.Sidechain -> uuid is NormalizedAgentContent.Summary -> null @@ -232,6 +250,7 @@ internal fun NormalizedAgentContent.parentUuidOrNull(): String? = when (this) { is NormalizedAgentContent.ToolUse -> parentUUID is NormalizedAgentContent.ToolResult -> parentUUID is NormalizedAgentContent.GeneratedImage -> parentUUID + is NormalizedAgentContent.DisplayLinks -> parentUUID is NormalizedAgentContent.CodexReviewContent -> parentUUID is NormalizedAgentContent.Sidechain -> parentUUID is NormalizedAgentContent.Summary -> null @@ -541,6 +560,18 @@ class GeneratedImageBlock( override val kind: String get() = "generated-image" } +class DisplayLinksBlock( + override val id: String, + val localId: String?, + override var createdAt: Long, + override var invokedAt: Long?, + val urls: List, + val texts: List, + override var meta: JsonElement?, +) : ChatBlock() { + override val kind: String get() = "display-links" +} + class AgentEventBlock( override val id: String, override var createdAt: Long, diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/FixtureProjection.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/FixtureProjection.kt index 07638bba0a..68d34589ad 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/FixtureProjection.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/FixtureProjection.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * Port of `web/scripts/fixtures/projection.ts` — the NORMATIVE projection of @@ -119,6 +121,23 @@ fun projectChatBlock(block: ChatBlock): JsonObject { projected["fileName"] = JsonPrimitive(block.fileName) projected["mimeType"] = nullableString(block.mimeType) } + is DisplayLinksBlock -> { + projected["localId"] = nullableString(block.localId) + projected["urls"] = JsonArray(block.urls.map { url -> + buildJsonObject { + put("href", url.href) + if (url.title != null) put("title", url.title) + } + }) + if (block.texts.isNotEmpty()) { + projected["texts"] = JsonArray(block.texts.map { text -> + buildJsonObject { + put("value", text.value) + if (text.title != null) put("title", text.title) + } + }) + } + } is AgentEventBlock -> { projected["event"] = block.event.raw } diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/NormalizeAgent.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/NormalizeAgent.kt index 81ade5ebda..27f7d5c712 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/NormalizeAgent.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/NormalizeAgent.kt @@ -36,6 +36,70 @@ private fun normalizeToolResultPermissions(value: JsonElement?): ToolResultPermi ) } +/** Port of shared `isDisplayableHttpHref` (http/https only). */ +internal fun isDisplayableHttpHref(href: String): Boolean { + val trimmed = href.trim() + if (trimmed.isEmpty() || trimmed.length > 2048) return false + return try { + // Validation copy only — keep original href bytes on DisplayLinkItem. + // java.net.URI rejects unescaped spaces that `new URL()` (web) accepts. + val uri = java.net.URI(trimmed.replace(" ", "%20")) + val scheme = uri.scheme?.lowercase() + scheme == "http" || scheme == "https" + } catch (_: Exception) { + false + } +} + +/** Port of shared `safeParseDisplayLinksInput`. */ +internal fun parseDisplayLinksInput(value: JsonElement?): List { + val items = value as? JsonArray ?: return emptyList() + val urls = mutableListOf() + for (item in items.take(20)) { + when (item) { + is JsonPrimitive -> { + if (!item.isString) continue + val href = item.content.trim() + if (!isDisplayableHttpHref(href)) continue + urls.add(NormalizedAgentContent.DisplayLinkItem(href = href)) + } + is JsonObject -> { + val rawHref = asString(item["href"].orNull() ?: item["url"]) ?: continue + val href = rawHref.trim() + if (!isDisplayableHttpHref(href)) continue + val title = asString(item["title"])?.trim()?.takeIf { it.isNotEmpty() } + urls.add(NormalizedAgentContent.DisplayLinkItem(href = href, title = title)) + } + else -> Unit + } + } + return urls +} + +/** Port of shared `safeParseDisplayTextsInput`. */ +internal fun parseDisplayTextsInput(value: JsonElement?): List { + val items = value as? JsonArray ?: return emptyList() + val texts = mutableListOf() + for (item in items.take(20)) { + when (item) { + is JsonPrimitive -> { + if (!item.isString) continue + val raw = item.content + if (raw.isEmpty() || raw.length > 8192 || raw.trim().isEmpty()) continue + texts.add(NormalizedAgentContent.DisplayTextItem(value = raw)) + } + is JsonObject -> { + val rawValue = asString(item["value"].orNull() ?: item["text"]) ?: continue + if (rawValue.isEmpty() || rawValue.length > 8192 || rawValue.trim().isEmpty()) continue + val title = asString(item["title"])?.trim()?.takeIf { it.isNotEmpty() } + texts.add(NormalizedAgentContent.DisplayTextItem(value = rawValue, title = title)) + } + else -> Unit + } + } + return texts +} + private fun normalizeAgentEvent(value: JsonElement?): AgentEvent? { val record = asObject(value) ?: return null if (asString(record["type"]) == null) return null @@ -808,6 +872,28 @@ fun normalizeAgentRecord( return eventMessage(messageId, localId, createdAt, data, meta) } + if (dataType == "display-links") { + val urls = parseDisplayLinksInput(data["urls"]) + val texts = parseDisplayTextsInput(data["texts"]) + if (urls.isEmpty() && texts.isEmpty()) return null + val uuid = asString(data["id"]) ?: messageId + return NormalizedMessage.Agent( + id = messageId, + localId = localId, + createdAt = createdAt, + isSidechain = false, + content = listOf( + NormalizedAgentContent.DisplayLinks( + urls = urls, + texts = texts, + uuid = uuid, + parentUUID = null, + ) + ), + meta = meta, + ) + } + if (dataType == "generated-image") { val imageId = asString(data["imageId"].orNull() ?: data["image_id"]) ?: return null val uuid = asString(data["id"]) ?: messageId diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/ReducerTimeline.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/ReducerTimeline.kt index 10921d0c63..45e2b625f3 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/ReducerTimeline.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/chat/ReducerTimeline.kt @@ -832,6 +832,20 @@ fun reduceTimeline( ) } + is NormalizedAgentContent.DisplayLinks -> { + blocks.add( + DisplayLinksBlock( + id = "${msg.id}:$idx", + localId = msg.localId, + createdAt = msg.createdAt, + invokedAt = msg.invokedAt, + urls = c.urls, + texts = c.texts, + meta = msg.meta, + ) + ) + } + is NormalizedAgentContent.Reasoning -> { val streamId = nonBlankStreamId(c.streamId) if (streamId != null) { diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageRetention.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageRetention.kt index 25af96d487..66c3041a46 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageRetention.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageRetention.kt @@ -1,5 +1,7 @@ package app.hapi.protocol.window +import app.hapi.protocol.chat.parseDisplayLinksInput +import app.hapi.protocol.chat.parseDisplayTextsInput import app.hapi.protocol.wire.stringOrNull import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -152,6 +154,9 @@ object MessageRetention { "agent-run-start", "agent-run-update", "agent-run-trace" -> true "generated-image" -> !asString(coalesce(prop(data, "imageId"), prop(data, "image_id"))).isNullOrEmpty() + "display-links" -> + parseDisplayLinksInput(prop(data, "urls")).isNotEmpty() || + parseDisplayTextsInput(prop(data, "texts")).isNotEmpty() "error" -> prop(data, "message").stringOrNull != null "message" -> prop(data, "message").stringOrNull != null "reasoning" -> prop(data, "message").stringOrNull != null diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/chat/ChatFixtureTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/chat/ChatFixtureTest.kt index a4256aaa7d..2908ccc566 100644 --- a/android/core/protocol/src/test/kotlin/app/hapi/protocol/chat/ChatFixtureTest.kt +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/chat/ChatFixtureTest.kt @@ -31,7 +31,7 @@ class ChatFixtureTest(private val fixtureName: String, private val file: File) { companion object { /** Highest chat-fixture document version this port implements. */ - const val SUPPORTED_FIXTURE_VERSION = 1 + const val SUPPORTED_FIXTURE_VERSION = 2 val fixturesDir: File by lazy { val path = System.getProperty("hapi.fixtures.dir") diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/FixtureDecodingTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/FixtureDecodingTest.kt index 029e681d23..9e59764a3a 100644 --- a/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/FixtureDecodingTest.kt +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/FixtureDecodingTest.kt @@ -1,5 +1,6 @@ package app.hapi.protocol.wire +import app.hapi.protocol.chat.ChatFixtureTest import java.io.File import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.JsonNull @@ -46,8 +47,8 @@ class FixtureDecodingTest { // supports (shared/fixtures/README.md policy) — never silently skip. val version = File(fixturesDir, "VERSION").readText().trim().toInt() assertTrue( - version <= SUPPORTED_PROTOCOL_VERSION, - "fixtures are version $version but this client supports <= $SUPPORTED_PROTOCOL_VERSION — update the port" + version <= ChatFixtureTest.SUPPORTED_FIXTURE_VERSION, + "fixtures are version $version but this client supports <= ${ChatFixtureTest.SUPPORTED_FIXTURE_VERSION} — update the port" ) } @@ -55,7 +56,11 @@ class FixtureDecodingTest { fun `every chat fixture's input messages decode as DecryptedMessage`() { for (file in chatFixtures()) { val root = HapiJson.parseToJsonElement(file.readText()).jsonObject - assertEquals(1, root.getValue("fixtureVersion").intOrNull, "fixtureVersion in ${file.name}") + assertEquals( + ChatFixtureTest.SUPPORTED_FIXTURE_VERSION, + root.getValue("fixtureVersion").intOrNull, + "fixtureVersion in ${file.name}" + ) val rawMessages = root.getValue("input").jsonObject.getValue("messages").jsonArray assertTrue(rawMessages.isNotEmpty(), "empty input.messages in ${file.name}") diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index cc47e19cd1..c31b7aff5c 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -72,6 +72,33 @@ describe('AcpMessageHandler', () => { expect(result.output).toEqual({ stdout: 'ok\n' }); }); + it('redacts display_links exact-copy values before emitting tool_call', () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + const secret = 'SENTINEL_SECRET_VK' + 'K'; + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'tool-links', + title: 'Display Links', + rawInput: { + urls: [{ href: 'https://example.com/public', title: 'Public' }], + texts: [{ value: secret, title: 'gate' }], + sessionId: 'abc', + }, + status: 'in_progress', + }); + + const call = messages.find((message): message is Extract => + message.type === 'tool_call' && message.id === 'tool-links' + ); + expect(call).toBeDefined(); + expect(JSON.stringify(call)).not.toContain(secret); + expect(call?.input).toMatchObject({ + texts: [{ value: '[omitted]', title: 'gate' }], + }); + }); + it('preserves intra-turn interleave order: text → tool_call → tool_result', () => { const messages: AgentMessage[] = []; const handler = new AcpMessageHandler((message) => messages.push(message)); diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 69aaec32c3..47c677deb8 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -3,12 +3,16 @@ import { logger } from '@/ui/logger'; import type { AgentMessage, PlanItem } from '@/agent/types'; import { registerGeneratedImageFromAcpBlock } from '@/modules/common/generatedImages'; import type { InlineMediaSource } from '@/modules/common/inlineMediaSource'; -import { asString, isObject } from '@hapi/protocol'; +import { asString, isObject, isDisplayLinksToolName, redactDisplayLinksToolInput } from '@hapi/protocol'; import { deriveToolNameWithSource, isPlaceholderToolName } from '@/agent/utils'; import { parseRateLimitText } from '@/agent/rateLimitParser'; import { isInternalEventJson } from '@/agent/internalEventFilter'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; +function redactIfDisplayLinks(name: string, input: unknown): unknown { + return isDisplayLinksToolName(name) ? redactDisplayLinksToolInput(input) : input; +} + function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' | 'failed' { if (status === 'in_progress' || status === 'completed' || status === 'failed') { return status; @@ -757,7 +761,7 @@ export class AcpMessageHandler { update.content ); // Content JSON can be `{}` (same as unusable rawInput); never lock that in. - const input = isUsableRawInput(candidate) ? candidate : null; + const input = redactIfDisplayLinks(name, isUsableRawInput(candidate) ? candidate : null); const status = normalizeStatus(update.status); this.toolCalls.set(toolCallId, { name, input }); @@ -787,7 +791,7 @@ export class AcpMessageHandler { if (isUsableRawInput(update.rawInput)) { const derivedName = deriveToolNameFromUpdate(update); const name = this.selectToolNameForUpdate(existing?.name ?? null, derivedName); - const input = update.rawInput; + const input = redactIfDisplayLinks(name, update.rawInput); this.toolCalls.set(toolCallId, { name, input }); this.onMessage({ type: 'tool_call', @@ -814,9 +818,9 @@ export class AcpMessageHandler { update.content ); if (isUsableRawInput(fallback)) { - input = fallback; const derivedName = deriveToolNameFromUpdate(update); name = this.selectToolNameForUpdate(existing.name ?? null, derivedName); + input = redactIfDisplayLinks(name, fallback); this.toolCalls.set(toolCallId, { name, input }); rederived = true; } 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/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index ed77f81a1f..41de29236f 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -240,6 +240,32 @@ describe('convertAgentMessage', () => { expect(convertAgentMessage({ type: 'not_a_real_type' } as never)).toBeNull(); }); + it('redacts display_links exact-copy values in persisted tool-call input', () => { + const secret = 'SENTINEL_SECRET_VK' + 'K'; + const converted = convertAgentMessage({ + type: 'tool_call', + id: 'call-links', + name: 'display_links', + input: { + urls: [{ href: 'https://example.com/public', title: 'Public' }], + texts: [{ value: secret, title: 'gate' }], + sessionId: 'abc', + }, + status: 'completed', + }); + + expect(converted).toMatchObject({ + type: 'tool-call', + name: 'display_links', + }); + expect(JSON.stringify(converted)).not.toContain(secret); + expect(converted).toMatchObject({ + input: { + texts: [{ value: '[omitted]', title: 'gate' }], + }, + }); + }); + it('converts generated_image messages into generated-image wire payloads', () => { const converted = convertAgentMessage({ type: 'generated_image', diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts index 4b53f155be..d540cf0ff4 100644 --- a/cli/src/agent/messageConverter.ts +++ b/cli/src/agent/messageConverter.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { INCLUSIVE_INPUT_TOKEN_USAGE_MARKER, type InclusiveInputTokenUsageMarker } from '@hapi/protocol/usage'; +import { isDisplayLinksToolName, redactDisplayLinksToolInput } from '@hapi/protocol'; import type { AgentMessage, PlanItem } from './types'; import type { InlineMediaSource } from '@/modules/common/inlineMediaSource'; @@ -102,7 +103,9 @@ export function convertAgentMessage(message: AgentMessage, model?: string | null type: 'tool-call', name: message.name, callId: message.id, - input: message.input, + input: isDisplayLinksToolName(message.name) + ? redactDisplayLinksToolInput(message.input) + : message.input, status: message.status, ...(message.title ? { nativeTitle: message.title } : {}), ...(message.kind ? { nativeKind: message.kind } : {}), diff --git a/cli/src/agent/permissionAdapter.test.ts b/cli/src/agent/permissionAdapter.test.ts index ca7094b1ec..a8557e53e3 100644 --- a/cli/src/agent/permissionAdapter.test.ts +++ b/cli/src/agent/permissionAdapter.test.ts @@ -458,4 +458,70 @@ describe('PermissionAdapter', () => { expect(intercept).toHaveBeenCalledOnce(); expect(harness.respondCalls).toHaveLength(1); }); + + it('redacts display_links exact-copy values in pending permission state', async () => { + const secret = 'SENTINEL_PERM_PENDING_VK' + 'K'; + const harness = createHarness(); + const toolName = 'hapi_2acd2599_525c_4774_825f_09ce7802549d_display_links'; + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-links', + toolCallId: 'perm-links', + title: toolName, + rawInput: { + urls: [{ href: 'https://example.com/public' }], + texts: [{ value: secret, title: 'gate' }] + } + })); + + const pending = harness.getAgentState().requests['perm-links'] as { + tool: string; + arguments: { texts: Array<{ value: string }> }; + }; + expect(pending.tool).toBe(toolName); + expect(JSON.stringify(pending)).not.toContain(secret); + expect(pending.arguments.texts[0]?.value).toBe('[omitted]'); + + const permissionRpc = harness.rpcHandlers.get('permission'); + await permissionRpc?.({ + id: 'perm-links', + approved: true, + decision: 'approved' + }); + + const completed = harness.getAgentState().completedRequests['perm-links'] as { + arguments: { texts: Array<{ value: string }> }; + }; + expect(JSON.stringify(completed)).not.toContain(secret); + expect(completed.arguments.texts[0]?.value).toBe('[omitted]'); + }); + + it('redacts display_links exact-copy values when auto-approved', async () => { + const secret = 'SENTINEL_PERM_AUTO_VK' + 'K'; + const harness = createHarnessWithMode(() => 'safe-yolo'); + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-links-auto', + toolCallId: 'perm-links-auto', + title: 'display_links', + rawInput: { + texts: [{ value: secret, title: 'gate' }] + } + })); + + await flushAsyncWork(); + + expect(harness.getAgentState().requests).toEqual({}); + const completed = harness.getAgentState().completedRequests['perm-links-auto'] as { + tool: string; + status: string; + arguments: { texts: Array<{ value: string }> }; + }; + expect(completed).toMatchObject({ + tool: 'display_links', + status: 'approved' + }); + expect(JSON.stringify(completed)).not.toContain(secret); + expect(completed.arguments.texts[0]?.value).toBe('[omitted]'); + }); }); diff --git a/cli/src/agent/permissionAdapter.ts b/cli/src/agent/permissionAdapter.ts index ac5ff54414..550fe9a989 100644 --- a/cli/src/agent/permissionAdapter.ts +++ b/cli/src/agent/permissionAdapter.ts @@ -2,7 +2,7 @@ import type { AgentBackend, PermissionRequest, PermissionResponse } from './type import type { AgentState, SessionPermissionMode } from '@/api/types'; import type { ApiSessionClient } from '@/api/apiSession'; import { logger } from '@/ui/logger'; -import { deriveToolInput, deriveToolName } from '@/agent/utils'; +import { deriveToolInput, deriveToolName, sanitizePermissionToolInput } from '@/agent/utils'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { resolveToolAutoApprovalDecision, @@ -60,7 +60,7 @@ export class PermissionAdapter { kind: request.kind, rawInput: request.rawInput }); - const input = deriveToolInput(request); + const input = sanitizePermissionToolInput(toolName, deriveToolInput(request)); const mode = this.getPermissionMode?.(); const autoDecision = resolveToolAutoApprovalDecision(mode, toolName, request.toolCallId); @@ -145,7 +145,7 @@ export class PermissionAdapter { kind: pending.kind, rawInput: pending.rawInput }); - const toolInput = deriveToolInput(pending); + const toolInput = sanitizePermissionToolInput(toolName, deriveToolInput(pending)); const outcome = this.mapDecisionToOutcome(pending, decision); if (decision === 'abort') { 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 d937ee54ce..5cbe132b9c 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/agent/utils.ts b/cli/src/agent/utils.ts index ceffa44ae1..c0964d454b 100644 --- a/cli/src/agent/utils.ts +++ b/cli/src/agent/utils.ts @@ -1,10 +1,15 @@ -import { isObject } from '@hapi/protocol'; +import { isDisplayLinksToolName, isObject, redactDisplayLinksToolInput } from '@hapi/protocol'; import type { PermissionRequest } from './types'; export function deriveToolInput(request: PermissionRequest): unknown { return request.rawInput !== undefined ? request.rawInput : request.rawOutput ?? null; } +/** Strip display_links exact-copy bytes before agentState / hub persistence. */ +export function sanitizePermissionToolInput(toolName: string, input: unknown): unknown { + return isDisplayLinksToolName(toolName) ? redactDisplayLinksToolInput(input) : input; +} + type ToolNameSource = 'title' | 'raw_input_name' | 'raw_input_tool' | 'kind' | 'default'; function normalizeToolName(value: unknown): string | null { diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 814d9ff6ac..28ba6897ff 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -5,8 +5,12 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { ApiSessionClient } from '@/api/apiSession' +import { buildDisplayLinksToolName } from '@hapi/protocol' import { startHappyServer, toClaudeAllowedHapiMcpTools } from './startHappyServer' +const TEST_SESSION_ID = 'happy-server-test-session' +const TEST_DISPLAY_LINKS_TOOL = buildDisplayLinksToolName(TEST_SESSION_ID) + type ToolResult = { content?: Array<{ type: string; text?: string }> isError?: boolean @@ -41,21 +45,26 @@ 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 = { + sessionId: TEST_SESSION_ID, 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( @@ -117,6 +126,7 @@ describe('startHappyServer skill_lookup', () => { 'list_peers', 'search_content', ]) + 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 +179,99 @@ 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 a per-session display_links tool for cursor flavor', async () => { + const mcp = await connect(true, { flavor: 'cursor' }) + const tools = await mcp.listTools() + expect(tools.tools.map((tool) => tool.name)).toContain(TEST_DISPLAY_LINKS_TOOL) + expect(tools.tools.map((tool) => tool.name)).not.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: TEST_DISPLAY_LINKS_TOOL, + arguments: { urls: [{ href, title: 'Issue 1516' }], sessionId: TEST_SESSION_ID } + }) 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('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: TEST_DISPLAY_LINKS_TOOL, + arguments: { texts: [{ value, title: 'gate' }], sessionId: TEST_SESSION_ID } + }) 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('refuses display_links when sessionId does not match the bound MCP session', async () => { + const mcp = await connect(false, { enableDisplayLinks: true }) + const href = 'https://example.com/kinrupt' + const result = await mcp.callTool({ + name: TEST_DISPLAY_LINKS_TOOL, + arguments: { urls: [{ href, title: 'Kinrupt' }], sessionId: '472632df-wrong-session' } + }) as ToolResult + + expect(result.isError).toBe(true) + expect(result.content?.[0]?.text).toMatch(/wrong-session/) + expect(sendAgentMessage).not.toHaveBeenCalled() + }) + + it('refuses display_links when sessionId is omitted', async () => { + const mcp = await connect(false, { enableDisplayLinks: true }) + const result = await mcp.callTool({ + name: TEST_DISPLAY_LINKS_TOOL, + arguments: { urls: [{ href: 'https://example.com/x' }] } + }) as ToolResult + + expect(result.isError).toBe(true) + expect(result.content?.[0]?.text).toMatch(/requires sessionId/) + expect(sendAgentMessage).not.toHaveBeenCalled() + }) + + it('rejects javascript hrefs without emitting an agent message', async () => { + const mcp = await connect(false, { enableDisplayLinks: true }) + const result = await mcp.callTool({ + name: TEST_DISPLAY_LINKS_TOOL, + arguments: { urls: [{ href: 'javascript:alert(1)' }], sessionId: TEST_SESSION_ID } + }) 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 = { + sessionId: 'happy-server-test-session', updateMetadata: vi.fn(), sendAgentMessage: vi.fn(), sendClaudeSessionMessage: vi.fn() diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 6731067c3a..bbcfb621c5 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, buildDisplayLinksToolName, parseDisplayLinksToolInput, assertBoundDisplayLinksSession } from "@hapi/protocol"; import { resolveSkill } from "@/modules/common/skills"; import { INSPECT_PEER_TOOL_DESCRIPTION, @@ -50,12 +51,24 @@ 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', @@ -81,7 +94,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); @@ -128,6 +142,24 @@ 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).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)'), + sessionId: z.string().min(1).optional().describe('This chat\'s HAPI session id (required at runtime). Cursor routes duplicate MCP tool names to one server; a mismatch means the call landed on the wrong session MCP.'), + }); + 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'), @@ -324,6 +356,69 @@ function createHapiMcpServer( } }); + if (enableDisplayLinks) { + const displayLinksToolName = buildDisplayLinksToolName(client.sessionId); + mcp.registerTool(displayLinksToolName, { + 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. Per-session tool name (ends with _display_links) so concurrent hapi-* MCP overlays do not collide. ${DISPLAY_LINKS_PROMPT_CURSOR}`, + title: 'Display Links', + inputSchema: displayLinksInputSchema, + }, async (args: { + urls?: Array<{ href: string; title?: string } | string> + texts?: Array<{ value: string; title?: string } | string> + sessionId?: string + }) => { + logger.debug( + '[hapiMCP] Display links: bound=', + client.sessionId, + 'caller=', + args.sessionId, + 'urls=', + args.urls?.length ?? 0, + 'texts=', + args.texts?.length ?? 0, + ); + + try { + assertBoundDisplayLinksSession(client.sessionId, args.sessionId); + const parsed = parseDisplayLinksToolInput(args); + client.sendAgentMessage(buildDisplayLinksPayload({ + 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 ${parts.join(', ')}`, + }, + ], + 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', @@ -552,11 +647,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) => { @@ -611,8 +707,12 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH })); const toolNames = enableChangeTitle - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'search_content', 'ping_peer', 'inspect_peer'] - : ['display_image', 'display_video', 'display_media', 'list_peers', 'search_content', 'ping_peer', 'inspect_peer']; + ? ['change_title', 'display_image', 'display_video', 'display_media'] + : ['display_image', 'display_video', 'display_media']; + if (enableDisplayLinks) { + toolNames.push(buildDisplayLinksToolName(client.sessionId)); + } + toolNames.push('list_peers', 'search_content', '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 c940d0bc50..3e9e9a2325 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,48 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { expect([...harness.tools.keys()]).toEqual(['change_title', 'display_image', 'display_video']) }) + it('forwards per-session display_links tool names unchanged', async () => { + const toolName = 'hapi_2acd2599_525c_4774_825f_09ce7802549d_display_links' + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + toolName + ]) + + const handler = harness.tools.get(toolName) + 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: toolName, + arguments: { urls: [{ href, title: 'Issue 1516' }] } + }) + }) + + it('forwards per-session display_links texts arguments unchanged', async () => { + const toolName = 'hapi_2acd2599_525c_4774_825f_09ce7802549d_display_links' + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + toolName + ]) + + const handler = harness.tools.get(toolName) + 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: toolName, + 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 840f85ee9b..31e2c95f12 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`, `search_content`, `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,8 @@ 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 { isDisplayLinksToolName } from '@hapi/protocol'; import { INSPECT_PEER_TOOL_DESCRIPTION, PING_PEER_TOOL_DESCRIPTION, @@ -201,6 +203,47 @@ 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).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)'), + sessionId: z.string().min(1).optional().describe('This chat\'s HAPI session id (required at runtime). Cursor routes duplicate MCP tool names to one server; a mismatch means the call landed on the wrong session MCP.'), + }); + + if (toolNames.has('display_links') || [...toolNames].some((name) => isDisplayLinksToolName(name))) { + const displayLinksToolName = [...toolNames].find((name) => isDisplayLinksToolName(name)) ?? 'display_links'; + server.registerTool( + displayLinksToolName, + { + 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, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + return await client.callTool({ name: displayLinksToolName, 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 c6192ab2a8..18d95d652e 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -9,13 +9,23 @@ const harness = vi.hoisted(() => ({ })) vi.mock('@/claude/utils/startHappyServer', () => ({ - startHappyServer: vi.fn(async (_client: unknown, options: { skillLookup?: unknown }) => { + startHappyServer: vi.fn(async (client: { sessionId?: string }, 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) { + const sid = (client.sessionId ?? 'test-session').replaceAll('-', '_') + names.push(`hapi_${sid}_display_links`) + } + names.push('list_peers', 'search_content', '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', 'search_content', 'ping_peer', 'inspect_peer', 'skill_lookup'] - : ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'search_content', 'ping_peer', 'inspect_peer'], + toolNames: names, stop: vi.fn() } }) @@ -64,6 +74,7 @@ describe('buildHapiMcpBridge skill lookup config', () => { expect(harness.startOptions).toEqual({ emitTitleSummary: undefined, + enableDisplayLinks: undefined, skillLookup }) expect(harness.cliArgs).toEqual([ @@ -82,6 +93,7 @@ describe('buildHapiMcpBridge skill lookup config', () => { search_content: { 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 () => { @@ -96,6 +108,18 @@ describe('buildHapiMcpBridge skill lookup config', () => { list_peers: { approval_mode: 'approve' }, search_content: { approval_mode: 'approve' } }) + expect(bridge.mcpServers.hapi.tools).not.toHaveProperty('display_links') + }) + + it('auto-approves per-session display_links for cursor sessions', async () => { + const bridge = await buildHapiMcpBridge(createClient({ sessionId: 'hub-session-1' }), { + enableDisplayLinks: true, + skillLookup: { workingDirectory: '/repo', flavor: 'cursor' } + }) + const toolName = 'hapi_hub_session_1_display_links' + expect(harness.cliArgs.at(-1)).toContain(toolName) + expect(bridge.mcpServers.hapi.tools?.[toolName]).toEqual({ approval_mode: 'approve' }) + expect(bridge.mcpServers.hapi.tools).not.toHaveProperty('display_links') }) 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 a2cc5e97a2..a22d336daa 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -10,6 +10,7 @@ import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import type { ApiSessionClient } from '@/api/apiSession'; import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv'; import type { CodexMcpServerConfig } from './codexMcpServers'; +import { isDisplayLinksToolName } from '@hapi/protocol'; /** * MCP server entry configuration. @@ -48,6 +49,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 +85,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([ @@ -102,6 +106,14 @@ export async function buildHapiMcpBridge( approval_mode: 'prompt' } }; + // Cursor-only (#1516) — per-session tool name; auto-approve so the model uses it + // instead of typing doubled-letter-mangled URLs. + const displayLinksToolName = happyServer.toolNames.find((name) => isDisplayLinksToolName(name)); + if (displayLinksToolName) { + tools[displayLinksToolName] = { + 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..14ee48d4be --- /dev/null +++ b/cli/src/commands/displayLinks.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { configuration } from '@/configuration' +import { + assertLocalDisplayLinksTarget, + displayLinksAuthRequestHeaders, + displayLinksSessionRequestHeaders, + 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, + texts: [], + title: 'Issue 1516', + readTextFromStdin: false, + }) + }) + + it('parses session prefix + href + title', () => { + expect(parseDisplayLinksArgs(['abc12345', 'https://example.com', 'Example'])).toEqual({ + help: false, + sessionArg: 'abc12345', + href: 'https://example.com', + texts: [], + title: 'Example', + readTextFromStdin: false, + }) + }) + + it('parses self token', () => { + expect(parseDisplayLinksArgs(['self', 'https://example.com'])).toEqual({ + help: false, + sessionArg: 'self', + href: 'https://example.com', + texts: [], + title: undefined, + readTextFromStdin: false, + }) + }) + + it('parses --help', () => { + expect(parseDisplayLinksArgs(['--help']).help).toBe(true) + }) + + 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, + readTextFromStdin: false, + }) + }) + + it('parses self --text', () => { + const value = 'dead' + 'beef' + expect(parseDisplayLinksArgs(['self', '--text', value])).toEqual({ + help: false, + sessionArg: 'self', + href: '', + texts: [{ value }], + title: undefined, + readTextFromStdin: false, + }) + }) + + it('parses --text-stdin without putting the secret on argv-derived texts', () => { + const parsed = parseDisplayLinksArgs(['--text-stdin', 'gate']) + expect(parsed.readTextFromStdin).toBe(true) + expect(parsed.texts).toEqual([]) + expect(parsed.title).toBe('gate') + expect(JSON.stringify(parsed)).not.toContain('SENTINEL') + }) + + it('throws when href and --text are missing', () => { + expect(() => parseDisplayLinksArgs([])).toThrow(/missing href|--text/) + expect(() => parseDisplayLinksArgs(['self'])).toThrow(/missing href|--text/) + }) +}) + +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/) + }) +}) + +describe('display-links hub extra headers', () => { + afterEach(() => { + configuration._setExtraHeaders({}) + }) + + it('includes configured extraHeaders on auth and session requests', () => { + configuration._setExtraHeaders({ Cookie: 'CF_Authorization=from-settings' }) + expect(displayLinksAuthRequestHeaders()).toEqual({ + Cookie: 'CF_Authorization=from-settings', + 'Content-Type': 'application/json', + }) + expect(displayLinksSessionRequestHeaders('jwt-token')).toEqual({ + Cookie: 'CF_Authorization=from-settings', + Authorization: 'Bearer jwt-token', + }) + }) +}) diff --git a/cli/src/commands/displayLinks.ts b/cli/src/commands/displayLinks.ts new file mode 100644 index 0000000000..32a27c2714 --- /dev/null +++ b/cli/src/commands/displayLinks.ts @@ -0,0 +1,311 @@ +import chalk from 'chalk' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { buildDisplayLinksToolName, parseDisplayLinksToolInput } from '@hapi/protocol' +import { getAuthToken } from '@/api/auth' +import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' +import { configuration } from '@/configuration' +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 + sessionArg: string | null + href: string + texts: Array<{ value: string; title?: string }> + title?: string + readTextFromStdin: boolean +} + +export function parseDisplayLinksArgs(args: string[]): ParsedDisplayLinksArgs { + if (args.includes('--help') || args.includes('-h')) { + return { help: true, sessionArg: null, href: '', texts: [], readTextFromStdin: false } + } + + const stdinIndex = args.indexOf('--text-stdin') + if (stdinIndex >= 0) { + if (args.includes('--text')) { + throw new Error('use --text or --text-stdin, not both') + } + const before = args.slice(0, stdinIndex) + const after = args.slice(stdinIndex + 1) + if (before.length > 1) { + throw new Error('usage: hapi display-links [self] --text-stdin [title]') + } + const session = before[0] && !looksLikeHref(before[0]) ? before[0] : null + const title = after[0] + return { + help: false, + sessionArg: session, + href: '', + texts: [], + title, + readTextFromStdin: true, + } + } + + 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] (secrets: --text-stdin)') + } + 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 }], + readTextFromStdin: false, + } + } + + if (args.length === 0) { + throw new Error('missing href or --text; usage: hapi display-links [|self] [title]') + } + + const firstLooksLikeHref = looksLikeHref(args[0] ?? '') + if (firstLooksLikeHref) { + return { + help: false, + sessionArg: null, + href: args[0]!, + texts: [], + title: args[1], + readTextFromStdin: false, + } + } + + if (args.length < 2) { + 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], + readTextFromStdin: false, + } +} + +function looksLikeHref(value: string): boolean { + return /^https?:\/\//i.test(value) +} + +function showHelp(): void { + console.log(` +${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 --text-stdin [title] + hapi display-links self --text [title] + hapi display-links self --text-stdin [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 and exact strings by concatenation in the calling script ("tia"+"nn", "VK"+"K"), never from model prose. + Secrets/tokens: use --text-stdin (value is not on argv). --text is for non-sensitive strings only. + 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 +`) +} + +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)) +} + +export function displayLinksAuthRequestHeaders(): Record { + return buildHubRequestHeaders({ 'Content-Type': 'application/json' }) +} + +export function displayLinksSessionRequestHeaders(jwt: string): Record { + return buildHubRequestHeaders({ Authorization: `Bearer ${jwt}` }) +} + +async function authHeaders(): Promise> { + const accessToken = getAuthToken() + const authRes = await fetch(`${configuration.apiUrl}/api/auth`, { + method: 'POST', + headers: displayLinksAuthRequestHeaders(), + 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 displayLinksSessionRequestHeaders(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 + } + + let texts = parsed.texts + if (parsed.readTextFromStdin) { + const value = (await Bun.stdin.text()).replace(/\r?\n$/, '') + if (!value.trim()) { + throw new Error('missing stdin exact-copy value; usage: hapi display-links --text-stdin [title]') + } + texts = parsed.title ? [{ value, title: parsed.title }] : [{ value }] + } + + const toolArgs = parseDisplayLinksToolInput({ + ...(parsed.href ? { + urls: parsed.title ? [{ href: parsed.href, title: parsed.title }] : [{ href: parsed.href }], + } : {}), + ...(texts.length > 0 ? { texts } : {}), + }) + + assertLocalDisplayLinksTarget(parsed.sessionArg) + + 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: buildDisplayLinksToolName(sessionId), + arguments: { + ...(toolArgs.urls.length > 0 ? { urls: toolArgs.urls } : {}), + ...(toolArgs.texts.length > 0 ? { texts: toolArgs.texts } : {}), + sessionId, + }, + }) + 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 edaf9452a6..360e8dc49d 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -22,6 +22,7 @@ import { hubCommand } from './hub' import { pingPeerCommand } from './pingPeer' import { inspectPeerCommand } from './inspectPeer' import { searchContentCommand } from './searchContent' +import { displayLinksCommand } from './displayLinks' import type { CommandContext, CommandDefinition } from './types' // Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on @@ -64,7 +65,8 @@ const COMMANDS: CommandDefinition[] = [ notifyCommand, pingPeerCommand, inspectPeerCommand, - searchContentCommand + searchContentCommand, + displayLinksCommand ] const commandMap = new Map() diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index b7fc9b5be8..367e1e637a 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/cursor/utils/cursorMcpOverlay.test.ts b/cli/src/cursor/utils/cursorMcpOverlay.test.ts index d5a2f182fc..1e825541ff 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: { @@ -89,6 +107,60 @@ describe('installCursorMcpOverlay', () => { expect(after.mcpServers[serverId]).toBeUndefined(); }); + it('keeps live sibling hapi overlays enabled when a second session installs', () => { + const cwd = makeProjectDir(JSON.stringify({ + mcpServers: { + other: { command: 'echo', args: ['x'] }, + 'hapi-docs': { command: 'echo', args: ['user-owned'] }, + }, + }, null, 2)); + const idA = cursorHapiMcpServerId('session-a'); + const idB = cursorHapiMcpServerId('session-b'); + const enabled: string[] = []; + const recordEnable = (_cwd: string, id: string) => { + enabled.push(id); + return { status: 0 }; + }; + + const handleA = installCursorMcpOverlay(cwd, { + command: '/bin/hapi', + args: ['mcp', '--url', 'http://127.0.0.1:1111/'], + }, { + serverId: idA, + enableCursorMcp: recordEnable, + mcpConfigDir: join(cwd, '.cursor'), + }); + + const handleB = installCursorMcpOverlay(cwd, { + command: '/bin/hapi', + args: ['mcp', '--url', 'http://127.0.0.1:2222/'], + }, { + serverId: idB, + enableCursorMcp: recordEnable, + mcpConfigDir: join(cwd, '.cursor'), + }); + + const mcpPath = join(cwd, '.cursor', 'mcp.json'); + const both = JSON.parse(readFileSync(mcpPath, 'utf-8')) as { + mcpServers: Record; + }; + expect(enabled).toEqual([idA, idB]); + expect(both.mcpServers[idA]).toBeDefined(); + expect(both.mcpServers[idB]).toBeDefined(); + expect(both.mcpServers['hapi-docs']).toEqual({ command: 'echo', args: ['user-owned'] }); + expect(both.mcpServers.other).toEqual({ command: 'echo', args: ['x'] }); + + handleB.cleanup(); + const afterB = JSON.parse(readFileSync(mcpPath, 'utf-8')) as { + mcpServers: Record; + }; + expect(afterB.mcpServers[idA]).toBeDefined(); + expect(afterB.mcpServers[idB]).toBeUndefined(); + expect(afterB.mcpServers['hapi-docs']).toEqual({ command: 'echo', args: ['user-owned'] }); + + handleA.cleanup(); + }); + it('leaves a newer session bridge intact when an older session cleans up first', () => { const cwd = makeProjectDir(JSON.stringify({ mcpServers: { @@ -383,23 +455,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') }); - expect(readFileSync(externalMcp, 'utf-8')).toBe(original); + 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'] }); + + 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..4c00b126f3 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 = { @@ -263,6 +295,12 @@ function sameMcpEntry(a: McpServerEntry | undefined, b: McpServerEntry | undefin * Merge the per-session HAPI stdio bridge into `~/.cursor/mcp.json` (or * `options.mcpConfigDir`) and approve it for Cursor's native MCP loader. * + * Concurrent `hapi-*` entries stay enabled. Each Cursor HappyServer registers a + * per-session `hapi__display_links` tool so bare-name collisions + * (forum 148059) cannot paint the wrong chat; `assertBoundDisplayLinksSession` + * remains defense-in-depth. Do not `agent mcp disable` siblings — cleanup never + * re-enables them, which strands a live session. + * * Cleanup undoes only the exact entry this session installed under `serverId` (or restores a * pre-existing value for that same id). Concurrent edits to other mcpServers keys — and to * this id when it no longer matches the installed overlay — survive the session. @@ -285,13 +323,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 48dfbf09e5..c37d9a29cd 100644 --- a/cli/src/modules/common/displayImagePrompt.ts +++ b/cli/src/modules/common/displayImagePrompt.ts @@ -55,3 +55,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 or a byte-accurate copyable string in this HAPI chat, call this session's display-links MCP tool (name ends with _display_links — unique per HAPI session so Cursor does not route a bare display_links collision to another chat) with { urls: [{ href, title? }], texts: [{ value, title? }], sessionId } (at least one of urls or texts). sessionId MUST be this chat's HAPI_SESSION_ID. 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/cli/src/modules/common/permission/AcpPermissionHandler.ts b/cli/src/modules/common/permission/AcpPermissionHandler.ts index 40d65725be..25ba691ba2 100644 --- a/cli/src/modules/common/permission/AcpPermissionHandler.ts +++ b/cli/src/modules/common/permission/AcpPermissionHandler.ts @@ -1,7 +1,7 @@ import type { ApiSessionClient } from '@/api/apiSession' import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types' import type { PermissionMode } from '@hapi/protocol/types' -import { deriveToolInput, deriveToolName } from '@/agent/utils' +import { deriveToolInput, deriveToolName, sanitizePermissionToolInput } from '@/agent/utils' import { logger } from '@/ui/logger' import { BasePermissionHandler, @@ -61,7 +61,7 @@ export class AcpPermissionHandler extends BasePermissionHandler { 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(' display_links is Cursor-only and per-session: hapi__display_links (requires sessionId).')) for (const session of cursorSessions) { const serverId = cursorHapiMcpServerId(session.id) console.log(chalk.gray(` Verify (${session.prefix}): agent mcp list-tools ${serverId}`)) + console.log(chalk.gray(` Display links tool (${session.prefix}): ${buildDisplayLinksToolName(session.id)}`)) } } 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 as hapi__display_links')) if (shellFallbackAvailable) { console.log(chalk.gray(' 2. Shell fallback (HAPI session id prefix, not cursorSessionId):')) if (withBridge.length > 0) { diff --git a/ios/Hapi/Features/Chat/Blocks/ChatBlockCard.swift b/ios/Hapi/Features/Chat/Blocks/ChatBlockCard.swift index 0064d05ce4..4352fd2c13 100644 --- a/ios/Hapi/Features/Chat/Blocks/ChatBlockCard.swift +++ b/ios/Hapi/Features/Chat/Blocks/ChatBlockCard.swift @@ -145,6 +145,8 @@ struct ChatSubBlockView: View { CliOutputBlockView(block: value) case .generatedImage(let value): GeneratedImageBlockView(block: value) + case .displayLinks(let value): + DisplayLinksBlockView(block: value) case .codexReview(let value): CodexReviewBlockView(block: value) case .toolCall(let value): diff --git a/ios/Hapi/Features/Chat/Blocks/DisplayLinksBlockView.swift b/ios/Hapi/Features/Chat/Blocks/DisplayLinksBlockView.swift new file mode 100644 index 0000000000..8846d51e71 --- /dev/null +++ b/ios/Hapi/Features/Chat/Blocks/DisplayLinksBlockView.swift @@ -0,0 +1,106 @@ +import HapiProtocol +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +/// Port of web `DisplayLinksCard`: tappable http(s) rows plus exact-copy strings. +struct DisplayLinksBlockView: View { + let block: DisplayLinksBlock + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(heading) + .font(.caption) + .foregroundStyle(.secondary) + ForEach(Array(block.urls.enumerated()), id: \.offset) { _, url in + if let link = displayableHttpURL(from: url.href) { + Link(destination: link) { + VStack(alignment: .leading, spacing: 2) { + Text(url.title?.isEmpty == false ? url.title! : url.href) + .font(.body.weight(.medium)) + .foregroundStyle(.link) + if let title = url.title, !title.isEmpty { + Text(url.href) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } else { + Text(url.title?.isEmpty == false ? url.title! : url.href) + .font(.body) + .foregroundStyle(.secondary) + } + } + ForEach(Array(block.texts.enumerated()), id: \.offset) { _, text in + Button { + copyExactValue(text.value) + } label: { + VStack(alignment: .leading, spacing: 4) { + if let title = text.title, !title.isEmpty { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(text.value) + .font(.body.monospaced()) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + Text("Tap to copy") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.secondary.opacity(0.25)) + ) + } + + private var heading: String { + let hasUrls = !block.urls.isEmpty + let hasTexts = !block.texts.isEmpty + if hasUrls && hasTexts { return "Links & copy" } + if hasTexts { return "Copy" } + return "Links" + } + + /// Local-only + short TTL — exact-copy cards may hold secrets/tokens. + private func copyExactValue(_ value: String) { + UIPasteboard.general.setItems( + [[UTType.utf8PlainText.identifier: value]], + options: [ + .localOnly: true, + .expirationDate: Date().addingTimeInterval(120), + ] + ) + } + + /// Build a tappable URL; escape spaces for Foundation while the card still shows original href bytes. + private func displayableHttpURL(from href: String) -> URL? { + let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines) + if let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" { + return url + } + guard let url = URL(string: trimmed.replacingOccurrences(of: " ", with: "%20")), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + return url + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift index d84c640915..761a50e862 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift @@ -464,6 +464,48 @@ public struct GeneratedImageContent: Equatable, Sendable { } } +/// Port of web `DisplayLinkItem` / shared `DisplayLink`. +public struct DisplayLinkItem: Equatable, Sendable { + public var href: String + public var title: String? + + public init(href: String, title: String? = nil) { + self.href = href + self.title = title + } +} + +/// Port of web `DisplayTextItem` / shared `DisplayText`. +public struct DisplayTextItem: Equatable, Sendable { + public var value: String + public var title: String? + + public init(value: String, title: String? = nil) { + self.value = value + self.title = title + } +} + +/// Port of `DisplayLinksContent` (normalizeAgent display-links agent content). +public struct DisplayLinksContent: Equatable, Sendable { + public var urls: [DisplayLinkItem] + public var texts: [DisplayTextItem] + public var uuid: String + public var parentUUID: String? + + public init( + urls: [DisplayLinkItem], + texts: [DisplayTextItem] = [], + uuid: String, + parentUUID: String? = nil + ) { + self.urls = urls + self.texts = texts + self.uuid = uuid + self.parentUUID = parentUUID + } +} + /// Port of `InlineMediaSource` (web/src/chat/inlineMediaSource.ts). public struct InlineMediaSource: Equatable, Sendable { public enum Ingress: String, Equatable, Sendable { @@ -581,6 +623,7 @@ public enum NormalizedAgentContent: Equatable, Sendable { case toolCall(ToolUseContent) case toolResult(ToolResultContent) case generatedImage(GeneratedImageContent) + case displayLinks(DisplayLinksContent) case codexReview(review: CodexReview, uuid: String, parentUUID: String?) case summary(String) case sidechain(SidechainContent) @@ -618,6 +661,7 @@ public enum NormalizedAgentContent: Equatable, Sendable { case .toolCall(let value): return value.uuid case .toolResult(let value): return value.uuid case .generatedImage(let value): return value.uuid + case .displayLinks(let value): return value.uuid case .codexReview(_, let uuid, _): return uuid case .summary: return nil case .sidechain(let value): return value.uuid @@ -630,6 +674,7 @@ public enum NormalizedAgentContent: Equatable, Sendable { case .toolCall(let value): return value.parentUUID case .toolResult(let value): return value.parentUUID case .generatedImage(let value): return value.parentUUID + case .displayLinks(let value): return value.parentUUID case .codexReview(_, _, let parentUUID): return parentUUID case .summary: return nil case .sidechain(let value): return value.parentUUID @@ -855,6 +900,16 @@ public struct GeneratedImageBlock: Equatable, Sendable { public var meta: JSONValue? } +public struct DisplayLinksBlock: Equatable, Sendable { + public var id: String + public var localId: String? + public var createdAt: Int + public var invokedAt: Int? + public var urls: [DisplayLinkItem] + public var texts: [DisplayTextItem] + public var meta: JSONValue? +} + public struct AgentEventBlock: Equatable, Sendable { public var id: String public var createdAt: Int @@ -886,6 +941,7 @@ public enum ChatBlock: Equatable, Sendable { case cliOutput(CliOutputBlock) case toolCall(ToolCallBlock) case generatedImage(GeneratedImageBlock) + case displayLinks(DisplayLinksBlock) case agentEvent(AgentEventBlock) /// The TS `kind` discriminator string. @@ -898,6 +954,7 @@ public enum ChatBlock: Equatable, Sendable { case .cliOutput: return "cli-output" case .toolCall: return "tool-call" case .generatedImage: return "generated-image" + case .displayLinks: return "display-links" case .agentEvent: return "agent-event" } } @@ -911,6 +968,7 @@ public enum ChatBlock: Equatable, Sendable { case .cliOutput(let block): return block.id case .toolCall(let block): return block.id case .generatedImage(let block): return block.id + case .displayLinks(let block): return block.id case .agentEvent(let block): return block.id } } @@ -924,6 +982,7 @@ public enum ChatBlock: Equatable, Sendable { case .cliOutput(let block): return block.createdAt case .toolCall(let block): return block.createdAt case .generatedImage(let block): return block.createdAt + case .displayLinks(let block): return block.createdAt case .agentEvent(let block): return block.createdAt } } @@ -937,6 +996,7 @@ public enum ChatBlock: Equatable, Sendable { case .cliOutput(let block): return block.invokedAt case .toolCall(let block): return block.invokedAt case .generatedImage(let block): return block.invokedAt + case .displayLinks(let block): return block.invokedAt case .agentEvent(let block): return block.invokedAt } } diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/FixtureProjection.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/FixtureProjection.swift index 9097db9eae..a3cb67bad0 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/FixtureProjection.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/FixtureProjection.swift @@ -97,6 +97,21 @@ public func projectChatBlock(_ block: ChatBlock) -> JSONValue { projected["fileName"] = .string(image.fileName) projected["mimeType"] = image.mimeType.map { JSONValue.string($0) } ?? .null return .object(projected) + case .displayLinks(let links): + projected["localId"] = localIdValue(links.localId) + projected["urls"] = .array(links.urls.map { url in + var object: [String: JSONValue] = ["href": .string(url.href)] + if let title = url.title { object["title"] = .string(title) } + return .object(object) + }) + if !links.texts.isEmpty { + projected["texts"] = .array(links.texts.map { text in + var object: [String: JSONValue] = ["value": .string(text.value)] + if let title = text.title { object["title"] = .string(title) } + return .object(object) + }) + } + return .object(projected) case .agentEvent(let eventBlock): // The normalized AgentEvent record is wire-semantic by construction: // carried verbatim. diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeAgent.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeAgent.swift index c9370ad538..f2c59221c0 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeAgent.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeAgent.swift @@ -20,6 +20,58 @@ func parseAgentTimestampMs(_ value: JSONValue?) -> Int? { return Int((date.timeIntervalSince1970 * 1000).rounded()) } +/// Port of shared `isDisplayableHttpHref` (fail-closed; http/https only). +func isDisplayableHttpHref(_ href: String) -> Bool { + let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 2048 else { return false } + // Validation copy only — keep original href bytes on DisplayLinkItem. + // Foundation `URL(string:)` rejects unescaped spaces that `new URL()` (web) accepts. + let validation = trimmed.replacingOccurrences(of: " ", with: "%20") + guard let url = URL(string: validation), let scheme = url.scheme?.lowercased() else { return false } + return scheme == "http" || scheme == "https" +} + +/// Port of shared `safeParseDisplayLinksInput`. +func parseDisplayLinksInput(_ value: JSONValue?) -> [DisplayLinkItem] { + guard let items = value?.arrayValue else { return [] } + var urls: [DisplayLinkItem] = [] + for item in items.prefix(20) { + if let href = item.stringValue { + let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines) + guard isDisplayableHttpHref(trimmed) else { continue } + urls.append(DisplayLinkItem(href: trimmed)) + continue + } + guard let object = item.objectValue else { continue } + let rawHref = asString(jsCoalesce(object["href"], object["url"])) + guard let href = rawHref?.trimmingCharacters(in: .whitespacesAndNewlines), + isDisplayableHttpHref(href) else { continue } + let title = asString(object["title"])?.trimmingCharacters(in: .whitespacesAndNewlines) + urls.append(DisplayLinkItem(href: href, title: (title?.isEmpty == false) ? title : nil)) + } + return urls +} + +/// Port of shared `safeParseDisplayTextsInput`. +func parseDisplayTextsInput(_ value: JSONValue?) -> [DisplayTextItem] { + guard let items = value?.arrayValue else { return [] } + var texts: [DisplayTextItem] = [] + for item in items.prefix(20) { + if let raw = item.stringValue { + guard !raw.isEmpty, raw.count <= 8192, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + texts.append(DisplayTextItem(value: raw)) + continue + } + guard let object = item.objectValue else { continue } + let rawValue = asString(jsCoalesce(object["value"], object["text"])) + guard let value = rawValue, !value.isEmpty, value.count <= 8192, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + let title = asString(object["title"])?.trimmingCharacters(in: .whitespacesAndNewlines) + texts.append(DisplayTextItem(value: value, title: (title?.isEmpty == false) ? title : nil)) + } + return texts +} + /// Port of `normalizeToolResultPermissions`. func normalizeToolResultPermissions(_ value: JSONValue?) -> ToolResultPermission? { guard let object = value?.objectValue else { return nil } @@ -1018,6 +1070,26 @@ func normalizeAgentRecord( ) } + if dataType == "display-links" { + let urls = parseDisplayLinksInput(data["urls"]) + let texts = parseDisplayTextsInput(data["texts"]) + if urls.isEmpty && texts.isEmpty { return nil } + let uuid = asString(data["id"]) ?? messageId + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.displayLinks(DisplayLinksContent( + urls: urls, + texts: texts, + uuid: uuid, + parentUUID: nil + ))]), + isSidechain: false, + meta: meta + ) + } + if dataType == "generated-image" { guard let imageId = asString(jsCoalesce(data["imageId"], data["image_id"])), !imageId.isEmpty else { return nil } let uuid = asString(data["id"]) ?? messageId diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift index 4d494c2361..8eef2fad95 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift @@ -841,6 +841,18 @@ func reduceTimeline(_ messages: [NormalizedMessage], context: ReducerContext) -> )))) continue + case .displayLinks(let links): + blocks.append(BlockBox(.displayLinks(DisplayLinksBlock( + id: "\(msg.id):\(idx)", + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + urls: links.urls, + texts: links.texts, + meta: msg.meta + )))) + continue + case .reasoning(let reasoningContent): let streamId = nonBlankStreamId(reasoningContent.streamId) if let streamId, let existingBox = reasoningBlocksByStreamId[streamId] { diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/PaginationFixtureTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/PaginationFixtureTests.swift index 16dbcbda51..e60c495f71 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/PaginationFixtureTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/PaginationFixtureTests.swift @@ -28,7 +28,7 @@ import Testing struct PaginationFixtureTests { /// Highest fixture document schema this suite understands. Mirrors the /// README rule: fail loudly when the on-disk version is newer. - private static let supportedFixtureVersion = 1 + private static let supportedFixtureVersion = 2 // MARK: - Fixture discovery (same #filePath scheme as ChatFixtureTests) diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/ChatFixtureTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/ChatFixtureTests.swift index a90117c14a..17e77c1f13 100644 --- a/ios/Packages/HapiKit/Tests/HapiProtocolTests/ChatFixtureTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/ChatFixtureTests.swift @@ -15,7 +15,7 @@ import Testing struct ChatFixtureTests { /// Highest fixture document schema this suite understands. Mirrors the /// README rule: fail loudly when the on-disk version is newer. - private static let supportedFixtureVersion = 1 + private static let supportedFixtureVersion = 2 private struct ChatFixtureDocument: Decodable { struct Input: Decodable { diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/FixtureDecodingTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/FixtureDecodingTests.swift index 5c46149fae..025d37962b 100644 --- a/ios/Packages/HapiKit/Tests/HapiProtocolTests/FixtureDecodingTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/FixtureDecodingTests.swift @@ -10,7 +10,7 @@ import Testing struct FixtureDecodingTests { /// Highest fixture document schema this suite understands. Mirrors the /// README rule: fail loudly when the on-disk version is newer. - private static let supportedFixtureVersion = 1 + private static let supportedFixtureVersion = 2 private struct ChatFixture: Decodable { struct Input: Decodable { diff --git a/scripts/tooling/hapi-display-links.mjs b/scripts/tooling/hapi-display-links.mjs new file mode 100644 index 0000000000..ff313443e8 --- /dev/null +++ b/scripts/tooling/hapi-display-links.mjs @@ -0,0 +1,263 @@ +#!/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] + * bun scripts/tooling/hapi-display-links.mjs --text-stdin [title] + * printf '%s' "$secret" | bun scripts/tooling/hapi-display-links.mjs --text-stdin + * 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. + */ + +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', '-']) +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) +} + +/** Mirror shared `buildDisplayLinksToolName` — keep in sync with displayLinks.ts. */ +function buildDisplayLinksToolName(sessionId) { + const id = String(sessionId ?? '').trim().replaceAll('-', '_') + if (!id) { + throw new Error('display_links tool name requires a non-empty session id') + } + return `hapi_${id}_display_links` +} + +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: +// [title] +// [title] +// --text [title] +// --text [title] +const args = process.argv.slice(2) +let sessionArg +let href +let title +let texts +const stdinIndex = args.indexOf('--text-stdin') +const textIndex = args.indexOf('--text') +if (stdinIndex >= 0) { + if (textIndex >= 0) { + console.error('use --text or --text-stdin, not both') + process.exit(2) + } + const before = args.slice(0, stdinIndex) + sessionArg = before[0] && !looksLikeHref(before[0]) ? before[0] : null + href = null + title = args[stdinIndex + 1] + const value = (await Bun.stdin.text()).replace(/\r?\n$/, '') + if (!value.trim()) { + console.error('usage: hapi-display-links.mjs [self] --text-stdin [title]') + process.exit(2) + } + texts = title ? [{ value, title }] : [{ value }] +} else 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] +} else { + sessionArg = args[0] + href = args[1] + title = args[2] +} + +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-display-links.mjs [self] --text-stdin [title] (secrets: not on argv)') + 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') + process.exit(2) +} + +function loadExtraHeaders() { + const envRaw = process.env.HAPI_EXTRA_HEADERS_JSON + if (envRaw !== undefined) { + try { + const parsed = JSON.parse(envRaw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return Object.fromEntries( + Object.entries(parsed).filter((entry) => typeof entry[1] === 'string'), + ) + } + } catch { + // ignore malformed env JSON; match CLI parseExtraHeaders fail-closed-to-empty + } + return {} + } + try { + const settings = JSON.parse(readFileSync(SETTINGS, 'utf8')) + const extra = settings.extraHeaders + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + return Object.fromEntries( + Object.entries(extra).filter((entry) => typeof entry[1] === 'string'), + ) + } + } catch { + // settings already parsed for token; missing extraHeaders is fine + } + return {} +} + +const extraHeaders = loadExtraHeaders() +function withHubHeaders(base) { + return { ...extraHeaders, ...base } +} + +const authRes = await fetch(`${HAPI_HOST}/api/auth`, { + method: 'POST', + headers: withHubHeaders({ '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 = withHubHeaders({ 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: buildDisplayLinksToolName(session.id), + arguments: { + ...(href ? { urls: title ? [{ href, title }] : [{ href }] } : {}), + ...(texts && texts.length > 0 ? { texts } : {}), + sessionId: session.id, + }, +}) +await client.close() +console.log(JSON.stringify(result, null, 2)) diff --git a/shared/fixtures/README.md b/shared/fixtures/README.md index e560eaa411..be7c6a4b56 100644 --- a/shared/fixtures/README.md +++ b/shared/fixtures/README.md @@ -71,11 +71,11 @@ cases do not bump the version. Runs the real web pipeline (`normalizeDecryptedMessage` → `reduceChatBlocks` → `buildVisibleChatBlocks`) over wire messages, then projects. -## Document schema (`fixtureVersion: 1`) +## Document schema (`fixtureVersion: 2`) ```jsonc { - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "claude-assistant-text", // equals the file name "description": "…", "input": { @@ -121,6 +121,7 @@ that carry it. | `cli-output` | `localId`, `text`, `source` (`'user' \| 'assistant'`) | | `codex-review` | `localId`, `review` (verbatim normalized review object) | | `generated-image` | `localId`, `imageId`, `fileName`, `mimeType` | +| `display-links` | `localId`, `urls` (`{ href, title? }[]`), `texts?` (`{ value, title? }[]`) | | `agent-event` | `event` — the normalized AgentEvent object verbatim (`type` + typed payload fields) | | `tool-call` | `localId`, `tool`, `children?` (recursively projected; omitted when empty) | | `tool-group` (visibleBlocks only) | `firstToolId`, `lastToolId`, `tools` (projected `tool-call` blocks in order; membership + order + count) | @@ -163,11 +164,11 @@ Pins the versioned-patch algorithm for `session-updated` events carrying a algorithm"). Expectations are computed by the real web fold — `applySessionDetailPatch` in `web/src/lib/sessionPatch.ts`. -## Document schema (`fixtureVersion: 1`) +## Document schema (`fixtureVersion: 2`) ```jsonc { - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "metadata-newer-version-applied", // equals the file name "description": "…", "initialSession": { /* full Session as cached before the first patch */ }, @@ -231,11 +232,11 @@ trimming (contract: `docs/api/client-contract/pagination.md`). Expectations are recorded from the real web store (`web/src/lib/message-window-store.ts`) driven by a scripted ApiClient. -## Document schema (`fixtureVersion: 1`) +## Document schema (`fixtureVersion: 2`) ```jsonc { - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "older-page-epoch-mismatch-resets", // equals the file name "description": "…", "ops": [ /* operation script, in order; see below */ ], diff --git a/shared/fixtures/VERSION b/shared/fixtures/VERSION index d00491fd7e..0cfbf08886 100644 --- a/shared/fixtures/VERSION +++ b/shared/fixtures/VERSION @@ -1 +1 @@ -1 +2 diff --git a/shared/fixtures/chat/agy-message.json b/shared/fixtures/chat/agy-message.json index bc1a3213ee..6ff3112021 100644 --- a/shared/fixtures/chat/agy-message.json +++ b/shared/fixtures/chat/agy-message.json @@ -22,7 +22,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/agy-tool-action-run-command.json b/shared/fixtures/chat/agy-tool-action-run-command.json index 67edb59480..4353fec2a7 100644 --- a/shared/fixtures/chat/agy-tool-action-run-command.json +++ b/shared/fixtures/chat/agy-tool-action-run-command.json @@ -40,7 +40,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-assistant-text.json b/shared/fixtures/chat/claude-assistant-text.json index f6c5d716ce..d4d1f3c292 100644 --- a/shared/fixtures/chat/claude-assistant-text.json +++ b/shared/fixtures/chat/claude-assistant-text.json @@ -43,7 +43,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-multi-block-image-result.json b/shared/fixtures/chat/claude-multi-block-image-result.json index 7b3eff5508..104e6482db 100644 --- a/shared/fixtures/chat/claude-multi-block-image-result.json +++ b/shared/fixtures/chat/claude-multi-block-image-result.json @@ -97,7 +97,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-system-api-error.json b/shared/fixtures/chat/claude-system-api-error.json index a8d3816fe6..bacfc3d18d 100644 --- a/shared/fixtures/chat/claude-system-api-error.json +++ b/shared/fixtures/chat/claude-system-api-error.json @@ -50,7 +50,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-system-away-summary.json b/shared/fixtures/chat/claude-system-away-summary.json index a07bafe6bd..fb0a8021eb 100644 --- a/shared/fixtures/chat/claude-system-away-summary.json +++ b/shared/fixtures/chat/claude-system-away-summary.json @@ -40,7 +40,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-system-compact-boundary.json b/shared/fixtures/chat/claude-system-compact-boundary.json index 939652eab2..d8870ecef9 100644 --- a/shared/fixtures/chat/claude-system-compact-boundary.json +++ b/shared/fixtures/chat/claude-system-compact-boundary.json @@ -42,7 +42,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-system-microcompact-boundary.json b/shared/fixtures/chat/claude-system-microcompact-boundary.json index 75eb9dc23b..144f23c2d6 100644 --- a/shared/fixtures/chat/claude-system-microcompact-boundary.json +++ b/shared/fixtures/chat/claude-system-microcompact-boundary.json @@ -44,7 +44,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-system-turn-duration.json b/shared/fixtures/chat/claude-system-turn-duration.json index 4b726ba406..a5a4dbe35c 100644 --- a/shared/fixtures/chat/claude-system-turn-duration.json +++ b/shared/fixtures/chat/claude-system-turn-duration.json @@ -36,7 +36,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-task-notification.json b/shared/fixtures/chat/claude-task-notification.json index f8d6ba2e56..d36eca036a 100644 --- a/shared/fixtures/chat/claude-task-notification.json +++ b/shared/fixtures/chat/claude-task-notification.json @@ -40,7 +40,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-thinking-block.json b/shared/fixtures/chat/claude-thinking-block.json index a5f34f6cc3..27ddd5ceb8 100644 --- a/shared/fixtures/chat/claude-thinking-block.json +++ b/shared/fixtures/chat/claude-thinking-block.json @@ -55,7 +55,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/claude-tool-use-result-pair.json b/shared/fixtures/chat/claude-tool-use-result-pair.json index 30240ed6ae..4743aab217 100644 --- a/shared/fixtures/chat/claude-tool-use-result-pair.json +++ b/shared/fixtures/chat/claude-tool-use-result-pair.json @@ -87,7 +87,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/cli-output-command-stdout-merge.json b/shared/fixtures/chat/cli-output-command-stdout-merge.json index c2e658c411..8d04df94fc 100644 --- a/shared/fixtures/chat/cli-output-command-stdout-merge.json +++ b/shared/fixtures/chat/cli-output-command-stdout-merge.json @@ -24,7 +24,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/cli-output-user-command.json b/shared/fixtures/chat/cli-output-user-command.json index d506814183..2555ef3b82 100644 --- a/shared/fixtures/chat/cli-output-user-command.json +++ b/shared/fixtures/chat/cli-output-user-command.json @@ -24,7 +24,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-blank-stream-ids.json b/shared/fixtures/chat/codex-blank-stream-ids.json index d2c0146433..e8bc195870 100644 --- a/shared/fixtures/chat/codex-blank-stream-ids.json +++ b/shared/fixtures/chat/codex-blank-stream-ids.json @@ -64,7 +64,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-context-compacted.json b/shared/fixtures/chat/codex-context-compacted.json index 7d64ff27f9..0465f1aa65 100644 --- a/shared/fixtures/chat/codex-context-compacted.json +++ b/shared/fixtures/chat/codex-context-compacted.json @@ -42,7 +42,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-error-event.json b/shared/fixtures/chat/codex-error-event.json index 3a82b1a0b4..d250596274 100644 --- a/shared/fixtures/chat/codex-error-event.json +++ b/shared/fixtures/chat/codex-error-event.json @@ -40,7 +40,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-exploration-tool-group.json b/shared/fixtures/chat/codex-exploration-tool-group.json index 8bfe663ac5..4366b4d760 100644 --- a/shared/fixtures/chat/codex-exploration-tool-group.json +++ b/shared/fixtures/chat/codex-exploration-tool-group.json @@ -159,7 +159,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-generated-image.json b/shared/fixtures/chat/codex-generated-image.json index 6aff6b0a67..c3e9fb00e6 100644 --- a/shared/fixtures/chat/codex-generated-image.json +++ b/shared/fixtures/chat/codex-generated-image.json @@ -26,7 +26,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-message-stream-snapshot.json b/shared/fixtures/chat/codex-message-stream-snapshot.json index 8937e68135..6883d61f1a 100644 --- a/shared/fixtures/chat/codex-message-stream-snapshot.json +++ b/shared/fixtures/chat/codex-message-stream-snapshot.json @@ -22,7 +22,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-plan-proposal-completed.json b/shared/fixtures/chat/codex-plan-proposal-completed.json index 76aef9faeb..d9e8bb17a4 100644 --- a/shared/fixtures/chat/codex-plan-proposal-completed.json +++ b/shared/fixtures/chat/codex-plan-proposal-completed.json @@ -98,7 +98,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-plan-proposal-pending.json b/shared/fixtures/chat/codex-plan-proposal-pending.json index 940919b5c5..d3933f2aad 100644 --- a/shared/fixtures/chat/codex-plan-proposal-pending.json +++ b/shared/fixtures/chat/codex-plan-proposal-pending.json @@ -102,7 +102,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "requests": { diff --git a/shared/fixtures/chat/codex-reasoning.json b/shared/fixtures/chat/codex-reasoning.json index 4b6c08c0dd..b7a3f5753f 100644 --- a/shared/fixtures/chat/codex-reasoning.json +++ b/shared/fixtures/chat/codex-reasoning.json @@ -22,7 +22,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-review-verdict.json b/shared/fixtures/chat/codex-review-verdict.json index 41365153de..039647961e 100644 --- a/shared/fixtures/chat/codex-review-verdict.json +++ b/shared/fixtures/chat/codex-review-verdict.json @@ -52,7 +52,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-shared-plan-available.json b/shared/fixtures/chat/codex-shared-plan-available.json index 45cfa49940..52509a252f 100644 --- a/shared/fixtures/chat/codex-shared-plan-available.json +++ b/shared/fixtures/chat/codex-shared-plan-available.json @@ -52,7 +52,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "codexPlanProposalId": "codex-proposed-plan:root:turn:plan-item" diff --git a/shared/fixtures/chat/codex-shared-plan-historical.json b/shared/fixtures/chat/codex-shared-plan-historical.json index 1606c81051..181a85743a 100644 --- a/shared/fixtures/chat/codex-shared-plan-historical.json +++ b/shared/fixtures/chat/codex-shared-plan-historical.json @@ -66,7 +66,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "codexPlanProposalId": null diff --git a/shared/fixtures/chat/codex-thread-goal-cleared.json b/shared/fixtures/chat/codex-thread-goal-cleared.json index d517921afa..75f597922f 100644 --- a/shared/fixtures/chat/codex-thread-goal-cleared.json +++ b/shared/fixtures/chat/codex-thread-goal-cleared.json @@ -22,7 +22,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-thread-goal-updated.json b/shared/fixtures/chat/codex-thread-goal-updated.json index 2d0fc255e8..b116175605 100644 --- a/shared/fixtures/chat/codex-thread-goal-updated.json +++ b/shared/fixtures/chat/codex-thread-goal-updated.json @@ -22,7 +22,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/codex-tool-call-result-pair.json b/shared/fixtures/chat/codex-tool-call-result-pair.json index ed04315f99..e45f0f91ba 100644 --- a/shared/fixtures/chat/codex-tool-call-result-pair.json +++ b/shared/fixtures/chat/codex-tool-call-result-pair.json @@ -70,7 +70,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/cursor-display-links.json b/shared/fixtures/chat/cursor-display-links.json new file mode 100644 index 0000000000..c2212cb887 --- /dev/null +++ b/shared/fixtures/chat/cursor-display-links.json @@ -0,0 +1,97 @@ +{ + "description": "Cursor display_links payload becomes a display-links block keeping {urls, texts}; exact-copy values are normative for native card rendering (web Share Turn / markdown export redact separately). Includes a path with an unescaped space (accepted by web URL; native validators must keep original href bytes).", + "expected": { + "blocks": [ + { + "createdAt": 1755000000000, + "id": "msg-cursor-links:0", + "kind": "display-links", + "localId": null, + "texts": [ + { + "title": "token", + "value": "exact-value" + } + ], + "urls": [ + { + "href": "https://example.com", + "title": "Example" + }, + { + "href": "https://example.com/report Q3.pdf", + "title": "Q3 report" + } + ] + } + ], + "hasReadyEvent": false, + "latestUsage": null, + "visibleBlocks": [ + { + "createdAt": 1755000000000, + "id": "msg-cursor-links:0", + "kind": "display-links", + "localId": null, + "texts": [ + { + "title": "token", + "value": "exact-value" + } + ], + "urls": [ + { + "href": "https://example.com", + "title": "Example" + }, + { + "href": "https://example.com/report Q3.pdf", + "title": "Q3 report" + } + ] + } + ] + }, + "fixtureVersion": 2, + "input": { + "agentState": null, + "messages": [ + { + "content": { + "content": { + "data": { + "id": "display-links-1", + "texts": [ + { + "title": "token", + "value": "exact-value" + } + ], + "type": "display-links", + "urls": [ + { + "href": "https://example.com", + "title": "Example" + }, + { + "href": "https://example.com/report Q3.pdf", + "title": "Q3 report" + } + ] + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000000000, + "id": "msg-cursor-links", + "localId": null, + "seq": 1 + } + ], + "options": { + "hasMoreMessages": false + } + }, + "name": "cursor-display-links" +} diff --git a/shared/fixtures/chat/cursor-plan-snapshot.json b/shared/fixtures/chat/cursor-plan-snapshot.json index 63cbe4513c..a5aa56b6f5 100644 --- a/shared/fixtures/chat/cursor-plan-snapshot.json +++ b/shared/fixtures/chat/cursor-plan-snapshot.json @@ -112,7 +112,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-abort-restore.json b/shared/fixtures/chat/event-abort-restore.json index 562c7db3a0..012d85ceb3 100644 --- a/shared/fixtures/chat/event-abort-restore.json +++ b/shared/fixtures/chat/event-abort-restore.json @@ -22,7 +22,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-api-error-folding.json b/shared/fixtures/chat/event-api-error-folding.json index bc61b7f4cc..30a4cb1106 100644 --- a/shared/fixtures/chat/event-api-error-folding.json +++ b/shared/fixtures/chat/event-api-error-folding.json @@ -64,7 +64,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-compact-summary.json b/shared/fixtures/chat/event-compact-summary.json index 0579c0334a..d0f76f1e4b 100644 --- a/shared/fixtures/chat/event-compact-summary.json +++ b/shared/fixtures/chat/event-compact-summary.json @@ -30,7 +30,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-error.json b/shared/fixtures/chat/event-error.json index fec5279a2e..a278841997 100644 --- a/shared/fixtures/chat/event-error.json +++ b/shared/fixtures/chat/event-error.json @@ -40,7 +40,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-limit-reached.json b/shared/fixtures/chat/event-limit-reached.json index b10d28d31e..9de9afdd64 100644 --- a/shared/fixtures/chat/event-limit-reached.json +++ b/shared/fixtures/chat/event-limit-reached.json @@ -42,7 +42,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-limit-warning-from-text.json b/shared/fixtures/chat/event-limit-warning-from-text.json index b977d749c5..b6e1fcc4b0 100644 --- a/shared/fixtures/chat/event-limit-warning-from-text.json +++ b/shared/fixtures/chat/event-limit-warning-from-text.json @@ -44,7 +44,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-message.json b/shared/fixtures/chat/event-message.json index 69e0e2d426..4cdcc36b5b 100644 --- a/shared/fixtures/chat/event-message.json +++ b/shared/fixtures/chat/event-message.json @@ -26,7 +26,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-ready.json b/shared/fixtures/chat/event-ready.json index 3ddc5590e1..975192dd27 100644 --- a/shared/fixtures/chat/event-ready.json +++ b/shared/fixtures/chat/event-ready.json @@ -22,7 +22,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-switch.json b/shared/fixtures/chat/event-switch.json index ae10d571ee..d3dbb225e2 100644 --- a/shared/fixtures/chat/event-switch.json +++ b/shared/fixtures/chat/event-switch.json @@ -26,7 +26,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-title-changed-dedupe.json b/shared/fixtures/chat/event-title-changed-dedupe.json index dcae1b85b5..98b3135caf 100644 --- a/shared/fixtures/chat/event-title-changed-dedupe.json +++ b/shared/fixtures/chat/event-title-changed-dedupe.json @@ -58,7 +58,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-turn-duration.json b/shared/fixtures/chat/event-turn-duration.json index c90445d4ca..bace7de8a6 100644 --- a/shared/fixtures/chat/event-turn-duration.json +++ b/shared/fixtures/chat/event-turn-duration.json @@ -36,7 +36,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/event-unknown-type.json b/shared/fixtures/chat/event-unknown-type.json index dfc16263b7..1257c77803 100644 --- a/shared/fixtures/chat/event-unknown-type.json +++ b/shared/fixtures/chat/event-unknown-type.json @@ -34,7 +34,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/permission-approved-for-session.json b/shared/fixtures/chat/permission-approved-for-session.json index ecc552b498..5578089b38 100644 --- a/shared/fixtures/chat/permission-approved-for-session.json +++ b/shared/fixtures/chat/permission-approved-for-session.json @@ -70,7 +70,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-ask-user-question-answered.json b/shared/fixtures/chat/permission-ask-user-question-answered.json index 8c4f489100..4516c7a55a 100644 --- a/shared/fixtures/chat/permission-ask-user-question-answered.json +++ b/shared/fixtures/chat/permission-ask-user-question-answered.json @@ -102,7 +102,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-canceled.json b/shared/fixtures/chat/permission-canceled.json index a338251859..770e9f2186 100644 --- a/shared/fixtures/chat/permission-canceled.json +++ b/shared/fixtures/chat/permission-canceled.json @@ -58,7 +58,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-denied-with-reason.json b/shared/fixtures/chat/permission-denied-with-reason.json index 9b8ddb03a7..6595988a63 100644 --- a/shared/fixtures/chat/permission-denied-with-reason.json +++ b/shared/fixtures/chat/permission-denied-with-reason.json @@ -62,7 +62,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-local-question-completed.json b/shared/fixtures/chat/permission-local-question-completed.json index a850910e85..a4f6b1546a 100644 --- a/shared/fixtures/chat/permission-local-question-completed.json +++ b/shared/fixtures/chat/permission-local-question-completed.json @@ -142,7 +142,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-local-question-pending.json b/shared/fixtures/chat/permission-local-question-pending.json index 7e9cb65900..f6790f0ef0 100644 --- a/shared/fixtures/chat/permission-local-question-pending.json +++ b/shared/fixtures/chat/permission-local-question-pending.json @@ -88,7 +88,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "controlledByUser": true, diff --git a/shared/fixtures/chat/permission-local-question-synthesized.json b/shared/fixtures/chat/permission-local-question-synthesized.json index 02f0d4aacc..4dadef984e 100644 --- a/shared/fixtures/chat/permission-local-question-synthesized.json +++ b/shared/fixtures/chat/permission-local-question-synthesized.json @@ -88,7 +88,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "controlledByUser": true, diff --git a/shared/fixtures/chat/permission-request-user-input-answered.json b/shared/fixtures/chat/permission-request-user-input-answered.json index e87edd6e06..b6460b8b07 100644 --- a/shared/fixtures/chat/permission-request-user-input-answered.json +++ b/shared/fixtures/chat/permission-request-user-input-answered.json @@ -124,7 +124,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-request-user-input-other-pending.json b/shared/fixtures/chat/permission-request-user-input-other-pending.json index 10412805e4..ea332f85c3 100644 --- a/shared/fixtures/chat/permission-request-user-input-other-pending.json +++ b/shared/fixtures/chat/permission-request-user-input-other-pending.json @@ -92,7 +92,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "requests": { diff --git a/shared/fixtures/chat/permission-request-user-input-other-with-note.json b/shared/fixtures/chat/permission-request-user-input-other-with-note.json index 1f1da188e3..8863a71006 100644 --- a/shared/fixtures/chat/permission-request-user-input-other-with-note.json +++ b/shared/fixtures/chat/permission-request-user-input-other-with-note.json @@ -128,7 +128,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-request-user-input-other-without-note.json b/shared/fixtures/chat/permission-request-user-input-other-without-note.json index 27192f53e5..b613c80edf 100644 --- a/shared/fixtures/chat/permission-request-user-input-other-without-note.json +++ b/shared/fixtures/chat/permission-request-user-input-other-without-note.json @@ -124,7 +124,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-shared-canceled.json b/shared/fixtures/chat/permission-shared-canceled.json index 491d39be66..f63aa04f74 100644 --- a/shared/fixtures/chat/permission-shared-canceled.json +++ b/shared/fixtures/chat/permission-shared-canceled.json @@ -100,7 +100,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-shared-pending.json b/shared/fixtures/chat/permission-shared-pending.json index 358770e289..70cea1caf5 100644 --- a/shared/fixtures/chat/permission-shared-pending.json +++ b/shared/fixtures/chat/permission-shared-pending.json @@ -94,7 +94,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "requests": { diff --git a/shared/fixtures/chat/permission-shared-resolved.json b/shared/fixtures/chat/permission-shared-resolved.json index 64a0317ddb..be02b407ae 100644 --- a/shared/fixtures/chat/permission-shared-resolved.json +++ b/shared/fixtures/chat/permission-shared-resolved.json @@ -100,7 +100,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": { diff --git a/shared/fixtures/chat/permission-synthesized-pending.json b/shared/fixtures/chat/permission-synthesized-pending.json index fdc66c7ebf..66ff7bbb33 100644 --- a/shared/fixtures/chat/permission-synthesized-pending.json +++ b/shared/fixtures/chat/permission-synthesized-pending.json @@ -58,7 +58,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": { "completedRequests": {}, diff --git a/shared/fixtures/chat/sidechain-task-nested-children.json b/shared/fixtures/chat/sidechain-task-nested-children.json index cec004bcc7..ac4a8afd8d 100644 --- a/shared/fixtures/chat/sidechain-task-nested-children.json +++ b/shared/fixtures/chat/sidechain-task-nested-children.json @@ -153,7 +153,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/tool-group-codex-shared-commands.json b/shared/fixtures/chat/tool-group-codex-shared-commands.json index 234402b7e4..78a44d4f9c 100644 --- a/shared/fixtures/chat/tool-group-codex-shared-commands.json +++ b/shared/fixtures/chat/tool-group-codex-shared-commands.json @@ -319,7 +319,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/tool-group-codex-user-shell-boundaries.json b/shared/fixtures/chat/tool-group-codex-user-shell-boundaries.json index 0baa8ae861..31e19f216a 100644 --- a/shared/fixtures/chat/tool-group-codex-user-shell-boundaries.json +++ b/shared/fixtures/chat/tool-group-codex-user-shell-boundaries.json @@ -294,7 +294,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/tool-group-consecutive-reads.json b/shared/fixtures/chat/tool-group-consecutive-reads.json index 84306f1dcf..8066874ab9 100644 --- a/shared/fixtures/chat/tool-group-consecutive-reads.json +++ b/shared/fixtures/chat/tool-group-consecutive-reads.json @@ -137,7 +137,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/tool-result-truncation-marker.json b/shared/fixtures/chat/tool-result-truncation-marker.json index da35c265d9..6c888074c5 100644 --- a/shared/fixtures/chat/tool-result-truncation-marker.json +++ b/shared/fixtures/chat/tool-result-truncation-marker.json @@ -40,7 +40,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/user-plain-text.json b/shared/fixtures/chat/user-plain-text.json index 69b3d4a765..fb55bc82c7 100644 --- a/shared/fixtures/chat/user-plain-text.json +++ b/shared/fixtures/chat/user-plain-text.json @@ -24,7 +24,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/chat/user-text-with-attachments.json b/shared/fixtures/chat/user-text-with-attachments.json index f0f42e6f7c..db234290d2 100644 --- a/shared/fixtures/chat/user-text-with-attachments.json +++ b/shared/fixtures/chat/user-text-with-attachments.json @@ -56,7 +56,7 @@ } ] }, - "fixtureVersion": 1, + "fixtureVersion": 2, "input": { "agentState": null, "messages": [ diff --git a/shared/fixtures/pagination/cancel-too-late-ingests-invoked-row.json b/shared/fixtures/pagination/cancel-too-late-ingests-invoked-row.json index fcc105ed06..a833b32675 100644 --- a/shared/fixtures/pagination/cancel-too-late-ingests-invoked-row.json +++ b/shared/fixtures/pagination/cancel-too-late-ingests-invoked-row.json @@ -25,7 +25,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "cancel-too-late-ingests-invoked-row", "ops": [ { diff --git a/shared/fixtures/pagination/fetch-older-before-cursor.json b/shared/fixtures/pagination/fetch-older-before-cursor.json index 45e78fd63b..63ccea653b 100644 --- a/shared/fixtures/pagination/fetch-older-before-cursor.json +++ b/shared/fixtures/pagination/fetch-older-before-cursor.json @@ -33,7 +33,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "fetch-older-before-cursor", "ops": [ { diff --git a/shared/fixtures/pagination/latest-page-then-sse-ingest.json b/shared/fixtures/pagination/latest-page-then-sse-ingest.json index 17f4f2a701..76575bc500 100644 --- a/shared/fixtures/pagination/latest-page-then-sse-ingest.json +++ b/shared/fixtures/pagination/latest-page-then-sse-ingest.json @@ -42,7 +42,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "latest-page-then-sse-ingest", "ops": [ { diff --git a/shared/fixtures/pagination/message-cancelled-removes-queued-row.json b/shared/fixtures/pagination/message-cancelled-removes-queued-row.json index 5f0de523ee..8207687b21 100644 --- a/shared/fixtures/pagination/message-cancelled-removes-queued-row.json +++ b/shared/fixtures/pagination/message-cancelled-removes-queued-row.json @@ -8,7 +8,7 @@ "olderCursor": null, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "message-cancelled-removes-queued-row", "ops": [ { diff --git a/shared/fixtures/pagination/messages-consumed-stamps-invoked-at.json b/shared/fixtures/pagination/messages-consumed-stamps-invoked-at.json index b3554e560e..b7a2e9c947 100644 --- a/shared/fixtures/pagination/messages-consumed-stamps-invoked-at.json +++ b/shared/fixtures/pagination/messages-consumed-stamps-invoked-at.json @@ -34,7 +34,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "messages-consumed-stamps-invoked-at", "ops": [ { diff --git a/shared/fixtures/pagination/older-page-epoch-mismatch-resets.json b/shared/fixtures/pagination/older-page-epoch-mismatch-resets.json index b8978f8ec0..c5ff454db7 100644 --- a/shared/fixtures/pagination/older-page-epoch-mismatch-resets.json +++ b/shared/fixtures/pagination/older-page-epoch-mismatch-resets.json @@ -24,7 +24,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "older-page-epoch-mismatch-resets", "ops": [ { diff --git a/shared/fixtures/pagination/optimistic-echo-reconciles-by-localid.json b/shared/fixtures/pagination/optimistic-echo-reconciles-by-localid.json index ba0fee095a..bd00d019ce 100644 --- a/shared/fixtures/pagination/optimistic-echo-reconciles-by-localid.json +++ b/shared/fixtures/pagination/optimistic-echo-reconciles-by-localid.json @@ -19,7 +19,7 @@ "olderCursor": null, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "optimistic-echo-reconciles-by-localid", "ops": [ { diff --git a/shared/fixtures/pagination/queued-state-reconciliation-drops-deleted.json b/shared/fixtures/pagination/queued-state-reconciliation-drops-deleted.json index c5666797b1..ef8844f02b 100644 --- a/shared/fixtures/pagination/queued-state-reconciliation-drops-deleted.json +++ b/shared/fixtures/pagination/queued-state-reconciliation-drops-deleted.json @@ -34,7 +34,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "queued-state-reconciliation-drops-deleted", "ops": [ { diff --git a/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json b/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json index 26ce456208..8b7901fc7f 100644 --- a/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json +++ b/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json @@ -60,7 +60,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "reasoning-snapshots-collapse-to-newest", "ops": [ { diff --git a/shared/fixtures/pagination/reset-latest-preserves-optimistic.json b/shared/fixtures/pagination/reset-latest-preserves-optimistic.json index 6146cf0831..4d2b3266a8 100644 --- a/shared/fixtures/pagination/reset-latest-preserves-optimistic.json +++ b/shared/fixtures/pagination/reset-latest-preserves-optimistic.json @@ -34,7 +34,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "reset-latest-preserves-optimistic", "ops": [ { diff --git a/shared/fixtures/pagination/sse-hidden-rows-advance-cursor-only.json b/shared/fixtures/pagination/sse-hidden-rows-advance-cursor-only.json index 689be3fc13..06f4cbcb20 100644 --- a/shared/fixtures/pagination/sse-hidden-rows-advance-cursor-only.json +++ b/shared/fixtures/pagination/sse-hidden-rows-advance-cursor-only.json @@ -33,7 +33,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "sse-hidden-rows-advance-cursor-only", "ops": [ { diff --git a/shared/fixtures/pagination/trim-preserves-queued-and-recomputes-cursor.json b/shared/fixtures/pagination/trim-preserves-queued-and-recomputes-cursor.json index 863895adb6..f6bb26ee3f 100644 --- a/shared/fixtures/pagination/trim-preserves-queued-and-recomputes-cursor.json +++ b/shared/fixtures/pagination/trim-preserves-queued-and-recomputes-cursor.json @@ -3615,7 +3615,7 @@ }, "viewMode": "tail" }, - "fixtureVersion": 1, + "fixtureVersion": 2, "name": "trim-preserves-queued-and-recomputes-cursor", "ops": [ { diff --git a/shared/fixtures/sse/active-turn-started-at-not-applied.json b/shared/fixtures/sse/active-turn-started-at-not-applied.json index c3e33dcd74..766f59ee74 100644 --- a/shared/fixtures/sse/active-turn-started-at-not-applied.json +++ b/shared/fixtures/sse/active-turn-started-at-not-applied.json @@ -30,7 +30,7 @@ "thinkingAt": 0, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/agent-state-out-of-order-versions.json b/shared/fixtures/sse/agent-state-out-of-order-versions.json index 06869e14f4..fbafe6deb7 100644 --- a/shared/fixtures/sse/agent-state-out-of-order-versions.json +++ b/shared/fixtures/sse/agent-state-out-of-order-versions.json @@ -40,7 +40,7 @@ "thinkingAt": 0, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/agent-state-stale-version-dropped.json b/shared/fixtures/sse/agent-state-stale-version-dropped.json index 379b6caa53..2446133c7a 100644 --- a/shared/fixtures/sse/agent-state-stale-version-dropped.json +++ b/shared/fixtures/sse/agent-state-stale-version-dropped.json @@ -43,7 +43,7 @@ "thinkingAt": 0, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/flat-fields-last-write-wins.json b/shared/fixtures/sse/flat-fields-last-write-wins.json index 1bd810cc15..6d7cd1b064 100644 --- a/shared/fixtures/sse/flat-fields-last-write-wins.json +++ b/shared/fixtures/sse/flat-fields-last-write-wins.json @@ -30,7 +30,7 @@ "thinkingAt": 0, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/keepalive-subminute-active-at-dropped.json b/shared/fixtures/sse/keepalive-subminute-active-at-dropped.json index a5efa8742a..ffb57ee52f 100644 --- a/shared/fixtures/sse/keepalive-subminute-active-at-dropped.json +++ b/shared/fixtures/sse/keepalive-subminute-active-at-dropped.json @@ -30,7 +30,7 @@ "thinkingAt": 0, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/metadata-equal-version-dropped.json b/shared/fixtures/sse/metadata-equal-version-dropped.json index b6b93e9c40..8cafacf9c9 100644 --- a/shared/fixtures/sse/metadata-equal-version-dropped.json +++ b/shared/fixtures/sse/metadata-equal-version-dropped.json @@ -29,7 +29,7 @@ "thinkingAt": 0, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/metadata-newer-version-applied.json b/shared/fixtures/sse/metadata-newer-version-applied.json index 6fcb6fbf13..7eb86f35a3 100644 --- a/shared/fixtures/sse/metadata-newer-version-applied.json +++ b/shared/fixtures/sse/metadata-newer-version-applied.json @@ -33,7 +33,7 @@ "thinkingAt": 0, "updatedAt": 1755000010000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/mixed-flat-and-versioned-patch.json b/shared/fixtures/sse/mixed-flat-and-versioned-patch.json index a486230808..f2166cb454 100644 --- a/shared/fixtures/sse/mixed-flat-and-versioned-patch.json +++ b/shared/fixtures/sse/mixed-flat-and-versioned-patch.json @@ -31,7 +31,7 @@ "thinkingAt": 0, "updatedAt": 1755000030000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/scratchlist-updated-at-trigger-only.json b/shared/fixtures/sse/scratchlist-updated-at-trigger-only.json index 5c66104dee..b2b776d5d1 100644 --- a/shared/fixtures/sse/scratchlist-updated-at-trigger-only.json +++ b/shared/fixtures/sse/scratchlist-updated-at-trigger-only.json @@ -29,7 +29,7 @@ "thinkingAt": 0, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/team-state-null-clear.json b/shared/fixtures/sse/team-state-null-clear.json index bbc8e89bfa..4369ce01e2 100644 --- a/shared/fixtures/sse/team-state-null-clear.json +++ b/shared/fixtures/sse/team-state-null-clear.json @@ -30,7 +30,7 @@ "thinkingAt": 0, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/todos-version-watermark-from-absent.json b/shared/fixtures/sse/todos-version-watermark-from-absent.json index 176cba9c94..579a628ced 100644 --- a/shared/fixtures/sse/todos-version-watermark-from-absent.json +++ b/shared/fixtures/sse/todos-version-watermark-from-absent.json @@ -46,7 +46,7 @@ "todosUpdatedAt": 1755000300000, "updatedAt": 1755000000000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/fixtures/sse/updated-at-max-monotonic.json b/shared/fixtures/sse/updated-at-max-monotonic.json index 7da39a469d..62a5d11a7f 100644 --- a/shared/fixtures/sse/updated-at-max-monotonic.json +++ b/shared/fixtures/sse/updated-at-max-monotonic.json @@ -31,7 +31,7 @@ "thinkingAt": 0, "updatedAt": 1755000005000 }, - "fixtureVersion": 1, + "fixtureVersion": 2, "initialSession": { "active": true, "activeAt": 1755000000000, diff --git a/shared/src/displayLinks.test.ts b/shared/src/displayLinks.test.ts new file mode 100644 index 0000000000..3c35a9f850 --- /dev/null +++ b/shared/src/displayLinks.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'bun:test' +import { + buildDisplayLinksPayload, + buildDisplayLinksToolName, + isDisplayableHttpHref, + parseDisplayLinksInput, + parseDisplayLinksToolInput, + parseDisplayTextsInput, + assertBoundDisplayLinksSession, + redactDisplayLinksToolInput, + isDisplayLinksToolName, + safeParseDisplayLinksInput, + safeParseDisplayTextsInput, +} 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('accepts paths with unescaped spaces (web URL parity)', () => { + expect(isDisplayableHttpHref('https://example.com/report Q3.pdf')).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('keeps unescaped spaces in stored href bytes', () => { + const href = 'https://example.com/report Q3.pdf' + expect(parseDisplayLinksInput([{ href, title: 'Q3' }])).toEqual([{ href, title: 'Q3' }]) + }) + + 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('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' + 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') + }) + + 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"/) + }) +}) + +describe('assertBoundDisplayLinksSession', () => { + const bound = 'cc4c5807-34ce-4f2f-b8c1-b18ddc191ff0' + + it('accepts an exact match', () => { + expect(() => assertBoundDisplayLinksSession(bound, bound)).not.toThrow() + }) + + it('rejects a missing caller id', () => { + expect(() => assertBoundDisplayLinksSession(bound, undefined)).toThrow(/requires sessionId/) + expect(() => assertBoundDisplayLinksSession(bound, '')).toThrow(/requires sessionId/) + }) + + it('rejects a Kinrupt id on a Sparling-bound server', () => { + const kinrupt = '472632df-0000-0000-0000-000000000000' + expect(() => assertBoundDisplayLinksSession(bound, kinrupt)).toThrow(/wrong-session/) + }) +}) + +describe('redactDisplayLinksToolInput', () => { + it('replaces exact-copy values and leaves urls intact', () => { + const secret = 'SENTINEL_SECRET_VK' + 'K' + const href = 'https://example.com/public' + const redacted = redactDisplayLinksToolInput({ + urls: [{ href, title: 'Public' }], + texts: [{ value: secret, title: 'gate' }], + sessionId: 'abc', + }) as { texts: Array<{ value: string; title?: string }>; urls: Array<{ href: string }> } + expect(JSON.stringify(redacted)).not.toContain(secret) + expect(redacted.texts[0]?.value).toBe('[omitted]') + expect(redacted.texts[0]?.title).toBe('gate') + expect(redacted.urls[0]?.href).toBe(href) + }) + + it('fail-closes when texts is malformed or carries extra secret fields', () => { + const secret = 'SENTINEL_BACKUP_VK' + 'K' + const objectTexts = redactDisplayLinksToolInput({ + texts: { value: secret }, + }) as { texts: string } + expect(objectTexts.texts).toBe('[omitted]') + expect(JSON.stringify(objectTexts)).not.toContain(secret) + + const withBackup = redactDisplayLinksToolInput({ + texts: [{ value: secret, backup: secret, title: 't' }], + backup: secret, + urls: [{ href: 'https://example.com', backup: secret }], + }) as { texts: Array>; urls: unknown[]; backup?: unknown } + expect(withBackup.texts[0]).toEqual({ value: '[omitted]', title: 't' }) + expect(withBackup).not.toHaveProperty('backup') + expect(JSON.stringify(withBackup)).not.toContain(secret) + expect(withBackup.texts[0]).not.toHaveProperty('backup') + expect(JSON.stringify(withBackup.urls)).not.toContain('backup') + }) + + it('fail-closes for primitive and array tool inputs', () => { + const secret = 'SENTINEL_PRIMITIVE_VK' + 'K' + expect(redactDisplayLinksToolInput(secret)).toBe('[omitted]') + expect(redactDisplayLinksToolInput([secret])).toBe('[omitted]') + expect(redactDisplayLinksToolInput(null)).toBeNull() + }) + + it('recognizes Cursor-prefixed display_links tool names', () => { + expect(isDisplayLinksToolName('display_links')).toBe(true) + expect(isDisplayLinksToolName('Display Links')).toBe(true) + expect(isDisplayLinksToolName('mcp__hapi__display_links')).toBe(true) + expect(isDisplayLinksToolName('hapi_2acd2599_525c_4774_825f_09ce7802549d_display_links')).toBe(true) + expect(isDisplayLinksToolName('Bash')).toBe(false) + }) + + it('builds a per-session display_links tool name', () => { + expect(buildDisplayLinksToolName('2acd2599-525c-4774-825f-09ce7802549d')).toBe( + 'hapi_2acd2599_525c_4774_825f_09ce7802549d_display_links', + ) + }) +}) diff --git a/shared/src/displayLinks.ts b/shared/src/displayLinks.ts new file mode 100644 index 0000000000..298980da1d --- /dev/null +++ b/shared/src/displayLinks.ts @@ -0,0 +1,280 @@ +/** + * 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 +export const MAX_DISPLAY_TEXT_LENGTH = 8192 + +const DENY_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file']) + +export type DisplayLink = { + href: string + title?: string +} + +export type DisplayText = { + value: string + title?: string +} + +export type DisplayLinksPayload = { + type: typeof DISPLAY_LINKS_PAYLOAD_TYPE + urls: DisplayLink[] + texts: DisplayText[] + 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 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 } +} + +/** + * Cursor routes duplicate MCP tool names to one server when many `hapi-*` + * entries share `display_links`. Require the caller session id so a mis-routed + * call cannot silently paint another chat. + */ +export function assertBoundDisplayLinksSession(boundSessionId: string, callerSessionId: unknown): void { + const bound = boundSessionId.trim() + if (!bound) { + throw new Error('display_links refused: this MCP server has no bound session id') + } + if (typeof callerSessionId !== 'string' || callerSessionId.trim() === '') { + throw new Error( + 'display_links requires sessionId matching this HAPI session (Cursor may route duplicate MCP tool names to the wrong server)', + ) + } + if (callerSessionId.trim() !== bound) { + throw new Error( + 'display_links refused: sessionId does not match this MCP server (wrong-session routing)', + ) + } +} + +export const DISPLAY_LINKS_REDACTED_VALUE = '[omitted]' as const + +/** + * Per-session MCP tool name so concurrent Cursor `hapi-*` overlays do not + * collide on bare `display_links` (forum 148059). Redaction matchers already + * accept names ending in `_display_links`. + */ +export function buildDisplayLinksToolName(sessionId: string): string { + const id = sessionId.trim().replaceAll('-', '_') + if (!id) { + throw new Error('display_links tool name requires a non-empty session id') + } + return `hapi_${id}_display_links` +} + +export function isDisplayLinksToolName(name: unknown): boolean { + if (typeof name !== 'string') return false + const normalized = name.trim().toLowerCase().replace(/[\s-]+/g, '_') + return normalized === 'display_links' + || normalized.endsWith('_display_links') + || normalized.endsWith('/display_links') +} + +/** Strip exact-copy bytes from a tool-call input record (hub/export must not store secrets). */ +export function redactDisplayLinksToolInput(input: unknown): unknown { + if (input == null) return input + if (typeof input !== 'object' || Array.isArray(input)) { + return DISPLAY_LINKS_REDACTED_VALUE + } + const record = input as Record + const safe: Record = {} + if (Object.prototype.hasOwnProperty.call(record, 'urls')) { + safe.urls = safeParseDisplayLinksInput(record.urls) + } + if (Object.prototype.hasOwnProperty.call(record, 'texts')) { + safe.texts = Array.isArray(record.texts) + ? record.texts.map((item) => { + if (typeof item === 'string') return DISPLAY_LINKS_REDACTED_VALUE + const row = item && typeof item === 'object' && !Array.isArray(item) + ? item as Record + : null + const title = typeof row?.title === 'string' ? row.title : undefined + return title === undefined + ? { value: DISPLAY_LINKS_REDACTED_VALUE } + : { value: DISPLAY_LINKS_REDACTED_VALUE, title } + }) + : DISPLAY_LINKS_REDACTED_VALUE + } + if (typeof record.sessionId === 'string') { + safe.sessionId = record.sessionId + } + return safe +} + +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[] + texts?: DisplayText[] + id: string +}): DisplayLinksPayload { + return { + type: DISPLAY_LINKS_PAYLOAD_TYPE, + urls: args.urls, + texts: args.texts ?? [], + 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/scripts/fixtures/cases/cursor.ts b/web/scripts/fixtures/cases/cursor.ts index 7f4a07532e..41b95dbe0a 100644 --- a/web/scripts/fixtures/cases/cursor.ts +++ b/web/scripts/fixtures/cases/cursor.ts @@ -44,5 +44,31 @@ export const cursorCases: FixtureCase[] = [ } }) ] + }, + { + name: 'cursor-display-links', + description: 'Cursor display_links payload becomes a display-links block keeping {urls, texts}; exact-copy values are normative for native card rendering (web Share Turn / markdown export redact separately). Includes a path with an unescaped space (accepted by web URL; native validators must keep original href bytes).', + messages: [ + wireMessage({ + id: 'msg-cursor-links', + seq: 1, + createdAt: T0, + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'display-links', + id: 'display-links-1', + urls: [ + { href: 'https://example.com', title: 'Example' }, + { href: 'https://example.com/report Q3.pdf', title: 'Q3 report' }, + ], + texts: [{ value: 'exact-value', title: 'token' }] + } + } + } + }) + ] } ] diff --git a/web/scripts/fixtures/fixtureTypes.ts b/web/scripts/fixtures/fixtureTypes.ts index 6c150a2203..8b271f613d 100644 --- a/web/scripts/fixtures/fixtureTypes.ts +++ b/web/scripts/fixtures/fixtureTypes.ts @@ -6,7 +6,7 @@ import type { AgentState, DecryptedMessage } from '@/types/api' * shared/fixtures/VERSION; native test suites must fail when the on-disk * version is newer than the one they support. */ -export const FIXTURE_VERSION = 1 +export const FIXTURE_VERSION = 2 /** * A hand-authored fixture case: wire-shaped inputs only. Everything under diff --git a/web/scripts/fixtures/projection.ts b/web/scripts/fixtures/projection.ts index ee2bf5d787..2834ed5ca7 100644 --- a/web/scripts/fixtures/projection.ts +++ b/web/scripts/fixtures/projection.ts @@ -106,6 +106,13 @@ export function projectChatBlock(block: ChatBlock): JsonObject { projected.fileName = block.fileName projected.mimeType = block.mimeType return projected + case 'display-links': + projected.localId = block.localId + projected.urls = block.urls + if (block.texts && block.texts.length > 0) { + projected.texts = block.texts + } + return projected case 'agent-event': // Normalized AgentEvent objects are wire-semantic by construction: // carried verbatim (type + typed payload fields). diff --git a/web/src/chat/normalizeAgent.test.ts b/web/src/chat/normalizeAgent.test.ts index 55a3a13e30..f941ffa0c6 100644 --- a/web/src/chat/normalizeAgent.test.ts +++ b/web/src/chat/normalizeAgent.test.ts @@ -280,3 +280,66 @@ 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('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', + 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..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 } 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' @@ -1016,6 +1016,28 @@ export function normalizeAgentRecord( } } + if (data.type === 'display-links') { + const urls = safeParseDisplayLinksInput(data.urls) + const texts = safeParseDisplayTextsInput(data.texts) + if (urls.length === 0 && texts.length === 0) return null + const uuid = asString(data.id) ?? messageId + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'display-links', + urls, + texts, + 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..b74b6a4198 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,28 @@ 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 (!areRoundSummariesEqual(left.roundSummary, right.roundSummary)) 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 + } + } + 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 +} + function areCodexReviewBlocksEqual(left: CodexReviewBlock, right: CodexReviewBlock): boolean { return left.review === right.review && left.localId === right.localId @@ -284,6 +307,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..10ee1531bf 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -1614,4 +1614,56 @@ 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' }], + texts: [], + 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') + }) + + 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 6cdf38ccfa..61d8e42a65 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -901,6 +901,20 @@ 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, + texts: c.texts ?? [], + 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..55d80c3c79 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -94,6 +94,24 @@ export type GeneratedImageContent = { source?: InlineMediaSource } +export type DisplayLinkItem = { + href: string + title?: string +} + +export type DisplayTextItem = { + value: string + title?: string +} + +export type DisplayLinksContent = { + type: 'display-links' + urls: DisplayLinkItem[] + texts: DisplayTextItem[] + uuid: string + parentUUID: string | null +} + export type CodexReviewFinding = { title: string body: string @@ -129,6 +147,7 @@ export type NormalizedAgentContent = | ToolUse | ToolResult | GeneratedImageContent + | DisplayLinksContent | { type: 'codex-review' review: CodexReview @@ -302,6 +321,18 @@ export type GeneratedImageBlock = { meta?: unknown } +export type DisplayLinksBlock = { + kind: 'display-links' + id: string + localId: string | null + createdAt: number + invokedAt?: number | null + urls: DisplayLinkItem[] + texts?: DisplayTextItem[] + roundSummary?: RoundSummary + meta?: unknown +} + export type AgentEventBlock = { kind: 'agent-event' id: string @@ -327,4 +358,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/ShareTurnDialog.displayLinks.test.tsx b/web/src/components/AssistantChat/ShareTurnDialog.displayLinks.test.tsx new file mode 100644 index 0000000000..283fe96d2d --- /dev/null +++ b/web/src/components/AssistantChat/ShareTurnDialog.displayLinks.test.tsx @@ -0,0 +1,58 @@ +import { render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { ShareTurnDialog } from '@/components/AssistantChat/ShareTurnDialog' + +describe('ShareTurnDialog display-links exact-copy', () => { + const matchMedia = window.matchMedia + + beforeEach(() => { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia + }) + + afterEach(() => { + window.matchMedia = matchMedia + }) + + it('strips exact-copy secret rows from the share capture while keeping URL rows', async () => { + const secret = 'SENTINEL_SECRET_VK' + 'K' + const html = [ + '
', + '
', + 'Public issue', + `
  • `, + '
    ', + '
    ', + ].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 new file mode 100644 index 0000000000..0add022e9a --- /dev/null +++ b/web/src/components/AssistantChat/messages/ToolMessage.displayLinks.test.tsx @@ -0,0 +1,95 @@ +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' + 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') + 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('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 } }) + + 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 }) + 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') + expect(copyButton.closest('li')).toHaveAttribute('data-hapi-share-exclude', 'true') + + fireEvent.click(copyButton) + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(value) + }) + }) + + it('does not make javascript hrefs tappable', () => { + 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 e977fb503e..24642e13d9 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useCallback, 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 { safeCopyToClipboard } from '@/lib/clipboard' import { isSubagentToolName } from '@/chat/subagentTool' import { ToolGroupCard } from '@/components/ToolCard/ToolGroupCard' import { getEventPresentation } from '@/chat/presentation' @@ -52,6 +53,129 @@ 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 + if (value.texts != null && !Array.isArray(value.texts)) return false + return true +} + +function DisplayLinksCopyRow(props: { value: string; title?: string }) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + const label = props.title?.trim() || t('displayLinks.exactString') + const handleCopy = useCallback(async () => { + 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 { 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 ( +
    +
    + {heading} +
    + {showLinks ? ( +
      + {props.block.urls.map((url, index) => { + const rowKey = `${url.href}:${index}` + const navigable = isDisplayableHttpHref(url.href) + const label = url.title?.trim() || url.href + if (!navigable) { + return ( +
    • + + {label} + +
    • + ) + } + return ( +
    • + + {label} + {url.title ? ( + + {url.href} + + ) : null} + +
    • + ) + })} +
    + ) : null} + {showTexts ? ( +
      + {texts.map((text, index) => ( + + ))} +
    + ) : null} +
    + ) +} + const MIN_INLINE_IMAGE_DIMENSION = 64 /** Scale tiny icons up for readability without exploding skinny/tall images. */ @@ -273,6 +397,14 @@ function HappyNestedBlockList(props: { ) } + if (block.kind === 'display-links') { + return ( +
    + +
    + ) + } + if (block.kind === 'agent-event') { const presentation = getEventPresentation(block.event) return ( @@ -361,6 +493,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/locales/en.ts b/web/src/lib/locales/en.ts index 7344164495..89cd8884d2 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -1223,4 +1223,11 @@ export default { 'session.summary.status.needsDecision': 'Needs decision', 'session.summary.status.failed': 'Failed', 'session.summary.status.stalled': 'Stalled', + 'displayLinks.heading.links': 'Links', + 'displayLinks.heading.texts': 'Exact copy', + 'displayLinks.heading.both': 'Links and exact copy', + 'displayLinks.tapToCopy': 'Tap to copy', + 'displayLinks.copied': 'Copied', + 'displayLinks.exactString': 'exact string', + 'displayLinks.copyAria': 'Copy {label}', } as const diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 73b2f73083..6f04bb6118 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -1221,4 +1221,11 @@ export default { 'session.summary.status.needsDecision': '需要决策', 'session.summary.status.failed': '失败', 'session.summary.status.stalled': '已停滞', + 'displayLinks.heading.links': '链接', + 'displayLinks.heading.texts': '精确复制', + 'displayLinks.heading.both': '链接和精确复制', + 'displayLinks.tapToCopy': '点按复制', + 'displayLinks.copied': '已复制', + 'displayLinks.exactString': '精确字符串', + 'displayLinks.copyAria': '复制 {label}', } as const diff --git a/web/src/lib/sessionExport/markdown.test.ts b/web/src/lib/sessionExport/markdown.test.ts index 50936076b6..21886da0bd 100644 --- a/web/src/lib/sessionExport/markdown.test.ts +++ b/web/src/lib/sessionExport/markdown.test.ts @@ -173,4 +173,77 @@ describe('serializeSessionMarkdown', () => { expect(markdown).not.toContain('sys-init') expect(markdown).toContain('- Tool: Bash') }) + + it('omits exact-copy values from display-links export markdown', () => { + const secret = 'SENTINEL_SECRET_VK' + 'K' + const href = 'https://example.com/public' + const markdown = serializeSessionMarkdown(makeExport([ + { + id: 'links-1', + seq: 1, + localId: null, + createdAt: Date.UTC(2026, 5, 5, 10, 3, 0), + invokedAt: Date.UTC(2026, 5, 5, 10, 3, 0), + scheduledAt: null, + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'display-links', + id: 'dl-1', + urls: [{ href, title: 'Public' }], + texts: [{ value: secret, title: 'gate' }], + } + } + } + } + ])) + + expect(markdown).toContain('Public') + expect(markdown).toContain(href) + expect(markdown).toContain('Exact-copy: gate (value omitted)') + expect(markdown).not.toContain(secret) + }) + + it('omits exact-copy values from a display_links tool-call input in export markdown', () => { + const secret = 'SENTINEL_TOOLCALL_VK' + 'K' + const markdown = serializeSessionMarkdown(makeExport([ + { + id: 'tool-links', + seq: 1, + localId: null, + createdAt: Date.UTC(2026, 5, 5, 10, 4, 0), + invokedAt: Date.UTC(2026, 5, 5, 10, 4, 0), + scheduledAt: null, + content: { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: 'assistant-links', + message: { + content: [ + { + type: 'tool_use', + id: 'toolu_links', + name: 'display_links', + input: { + texts: [{ value: secret, title: 'gate' }], + sessionId: 'abc', + } + } + ] + } + } + } + } + } + ])) + + expect(markdown).toContain('display_links') + expect(markdown).not.toContain(secret) + expect(markdown).toContain('[omitted]') + }) }) diff --git a/web/src/lib/sessionExport/markdown.ts b/web/src/lib/sessionExport/markdown.ts index 1d50ebb653..ee7afd689c 100644 --- a/web/src/lib/sessionExport/markdown.ts +++ b/web/src/lib/sessionExport/markdown.ts @@ -1,4 +1,4 @@ -import { safeStringify } from '@hapi/protocol' +import { safeStringify, isDisplayLinksToolName, redactDisplayLinksToolInput } from '@hapi/protocol' import { normalizeDecryptedMessage } from '@/chat/normalize' import { renderEventLabel } from '@/chat/presentation' import type { NormalizedAgentContent, NormalizedMessage } from '@/chat/types' @@ -72,9 +72,10 @@ function truncate(value: string, maxLength: number): string { return `${value.slice(0, maxLength - 1)}…` } -function formatToolInput(input: unknown): string { - if (input == null) return '' - const text = safeStringify(input).trim() +function formatToolInput(name: string, input: unknown): string { + const safeInput = isDisplayLinksToolName(name) ? redactDisplayLinksToolInput(input) : input + if (safeInput == null) return '' + const text = safeStringify(safeInput).trim() return text ? ` — ${truncate(text.replace(/\s+/g, ' '), 160)}` : '' } @@ -85,7 +86,7 @@ function formatAgentContentBlock(block: NormalizedAgentContent): string | null { case 'reasoning': return `> Reasoning: ${block.text}` case 'tool-call': - return `- Tool: ${block.name}${formatToolInput(block.input)}` + return `- Tool: ${block.name}${formatToolInput(block.name, block.input)}` case 'tool-result': { const label = block.is_error ? 'Tool error' : 'Tool result' const content = safeStringify(block.content).trim() @@ -93,6 +94,11 @@ function formatAgentContentBlock(block: NormalizedAgentContent): string | null { } case 'generated-image': return `- Generated image: ${block.fileName}` + case 'display-links': { + const links = block.urls.map((url) => `- Link: ${url.title ? `${url.title} (${url.href})` : url.href}`) + const texts = (block.texts ?? []).map((text) => `- Exact-copy: ${text.title?.trim() || 'card'} (value omitted)`) + return [...links, ...texts].join('\n') || null + } case 'codex-review': return `- Codex review: ${block.review.overallCorrectness ?? 'review'} (${block.review.findings.length} findings)` case 'summary':