Junmo KimandGitHub 6e32b20524 feat(web): linkify custom URI schemes with a confirm prompt (#633)
* feat(web): add UriConfirmDialog component

Add a Radix Dialog-based confirmation modal for custom URI scheme
navigation. Follows the RenameSessionDialog pattern.

- UriConfirmDialog: shows URI, scheme label, Cancel/Open/Always-allow buttons
- i18n keys: dialog.uri.{title,description,open,alwaysAllow}

* feat(web): autolink non-https URI schemes in markdown

Add a remark plugin that converts raw `scheme://...` text nodes into
link nodes for non-http(s) schemes. GFM already handles http/https;
this plugin handles the remainder (obsidian://, vscode://, slack://, etc.).

- No scheme allowlist: every `scheme://` pattern is converted; the
  sanitize layer (urlTransform) and onClick layer (classifyScheme) handle
  blocking/confirmation downstream.
- Runs before remarkStripCjkAutolink so the CJK-strip plugin sees the
  new link nodes and can trim trailing CJK punctuation from them.
- Trailing punctuation (.,;!?) stripped from matched URIs.
- Unit tests: conversion, partial-match, escape, explicit link bypass,
  code-block bypass, trailing-punct trimming.

* feat(web): linkify custom URI schemes via markdown <a> handler

Wire up 4-layer URI security policy in the markdown renderer:

1. URL sanitize (deny-only): urlTransform strips javascript:/data:/vbscript:/file:
   using classifyScheme as single source of truth (handles percent-encoding,
   case-insensitive, whitespace-prefix bypass patterns).

2. onClick intercept: custom <A> component classifies each href —
   - IANA safe (https/http/irc/ircs/mailto/xmpp): navigate directly.
   - Deny (javascript/data/vbscript/file): preventDefault silently.
   - Custom (obsidian/vscode/slack/…): preventDefault + open UriConfirmDialog.

3. UriConfirmProvider: one dialog lifted to each markdown root (MarkdownText,
   Reasoning, MarkdownRenderer). Shared isAllowed state across all <a> tags in
   the subtree — "Always allow" click updates every link in one React commit.

4. Intra-tab cross-provider sync (P7e.1): module-level schemeListeners Set so
   sibling UriConfirmProviders (MarkdownText + Reasoning in AssistantMessage)
   receive allowed-scheme updates synchronously without waiting for the window
   storage event (which only fires in other tabs). Cross-tab sync continues via
   the existing window storage event listener.

5. "Always allow" persisted to localStorage (hapi-allowed-schemes). Custom
   schemes once allowed navigate directly on subsequent clicks, no dialog gate.
   href="#" in DOM for unallowed custom schemes prevents middle-click bypass.
   Deny-scheme href="" prevents any navigation even if localStorage tampered.

Security: classifyScheme decodes percent-encoding before scheme extraction,
blocking %6Aavascript:, jav%61script:, javascript%3A (single-encoded colon)
and double-encoded variants. DENY_SCHEMES checked after localStorage lookup so
tampered allowed-list cannot promote deny schemes.

Tests: classifyScheme 6-axis security bypass, denyOnlyTransform, localStorage
roundtrip, cross-tab storage event, <A> click handler cases.

* fix(web): block control-char-spliced deny schemes in classifyScheme

Browsers silently strip ASCII control characters (\t, \n, \r) and
whitespace from URL scheme names during navigation. A scheme like
`java\nscript:alert(1)` was navigated as `javascript:` while our
literal string comparison classified it as 'custom', allowing it
past the deny list and into window.open().

Introduce normalizedScheme() that:
- applies 2 rounds of decodeURIComponent so double-encoded schemes
  (javascript%253A → javascript%3A → javascript:) are fully unwrapped
  before comparison
- strips [\x00-\x1F\x7F\s] from the extracted scheme name, matching
  the browser's own normalization

classifyScheme() now delegates to normalizedScheme() so both the
denyOnlyTransform (urlTransform) path and the <A> onClick path benefit
from the same normalization.

Tests added for \n / \t / \r / space spliced into scheme, and verify
that double-encoded colon is now caught via scheme-match (not just
the no-colon fallback).

* fix(web): preserve relative markdown links from being blocked

Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) were
silently preventDefault'd in <A>'s onClick handler. denyOnlyTransform
correctly passed them through (no colon → not a scheme URL), but the
click handler called classifyScheme(href) which returned 'deny' for
any input with no valid scheme separator — then the deny branch fired.

Add hasScheme(href): checks whether the first ':' appears before any
path/query/fragment boundary ('/', '?', '#'). When hasScheme is false
the href is treated as 'iana' so the browser or SPA router can navigate
normally with no dialog and no preventDefault.

Also wrap renderA() with <I18nProvider> so the UriConfirmDialog that
UriConfirmProvider may render does not throw outside its translation
context during tests.

Fixes a regression that broke all relative-path markdown links once the
custom-URI-scheme onClick handler was added.

* test(web): cover percent-encoded scheme control char + protocol-relative href

Round-5 internal hostile review noted two coverage gaps on the bot-fixup commits:

- `java%0Ascript:alert(1)` (percent-encoded newline in the scheme name) takes the
  same decode→strip code path as the literal `java\nscript:` case but was only
  tested literally. Add an explicit test so a future refactor that drops the
  decode-then-strip ordering would be caught.
- Protocol-relative URLs (`//host/path`) have no colon, so `hasScheme` returns
  false and `<A>` treats them as scheme-less — browsers then navigate them as
  the current origin's protocol. Existing relative-href tests covered absolute
  paths, hashes, queries, and colon-in-path, but not the protocol-relative
  variant. Add one assertion.

Also extend the `hasScheme` JSDoc to note that protocol-relative URLs are
intentionally treated as scheme-less.

* fix(web): preserve balanced parens/brackets in autolinked URIs

The trailing-punctuation strip used to drop every `)` / `]` from the end
of a matched URI, even when the URL body had an unmatched opener. So a
URI like `obsidian://open?file=Note(1)` was rendered with href
`obsidian://open?file=Note(1` plus a separate `)` text node, opening a
broken deep link.

Match the GFM autolink-literal behaviour: when the trailing character is
`)` or `]`, keep it iff the URL body has more opening counterparts than
closers (so the trailing closer balances an earlier opener and belongs
to the URL). Other trailing punctuation (`.,;!?:>'"`) and unmatched
closers still strip as before.

Add tests for the balanced cases (`Note(1)`, `Note[1]`, nested
`(a(b)c)`), the "balanced URL followed by a period" case, and a
regression test that an unmatched `).` after a URL is still stripped.
2026-05-18 10:40:32 +08:00
2026-05-15 23:05:39 +08:00
2026-03-06 20:26:18 +08:00
2026-01-04 20:45:15 +08:00
2026-03-29 09:47:17 +08:00
2025-12-16 15:03:50 +08:00

HAPI

Run official Claude Code / Codex / Gemini / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App.

Why HAPI? HAPI is a local-first alternative to Happy. See Why Not Happy? for the key differences.

Features

  • Seamless Handoff - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart.
  • Native First - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory.
  • AFK Without Stopping - Step away from your desk? Approve AI requests from your phone with one tap.
  • Your AI, Your Choice - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow.
  • Terminal Anywhere - Run commands from your phone or browser, directly connected to the working machine.
  • Voice Control - Talk to your AI agent hands-free using the built-in voice assistant.
  • Workspace Browser - Opt-in via one or more hapi runner start --workspace-root <path> flags: browse scoped file trees from the web and start sessions in allowed subdirectories.

Demo

https://github.com/user-attachments/assets/38230353-94c6-4dbe-9c29-b2a2cc457546

Getting Started

npx @twsxtd/hapi hub --relay     # start hub with E2E encrypted relay
npx @twsxtd/hapi                 # run claude code

hapi server remains supported as an alias.

The terminal will display a URL and QR code. Scan the QR code with your phone or open the URL to access.

The relay uses WireGuard + TLS for end-to-end encryption. Your data is encrypted from your device to your machine.

For self-hosted options (Cloudflare Tunnel, Tailscale), see Installation

Docs

Build from source

bun install
bun run build:single-exe

Credits

HAPI means "哈皮" a Chinese transliteration of Happy. Great credit to the original project.

S
Description
GitHub 镜像: wu736139669/hapi(每日自动同步)
Readme AGPL-3.0
103 MiB
Languages
TypeScript 99.5%
CSS 0.2%
JavaScript 0.2%
HTML 0.1%