# Local Web Surface

> Build or extend a persistent macOS local web surface when the request includes a stable *.localhost URL, an existing loopback daemon/gateway, login startup, or a thin .app launcher that opens or wakes a persistent local HTTP surface. Once triggered, it can add file-backed CRUD or local/public content projections without changing public publishing. Covers domain ownership, exact Host routing, explicit access/threat boundaries, safe mutations, launchd lifecycle, TDD, curl checks, and proportional browser QA. Do not use for public or remote URL launchers, public hosting, production deployment, ordinary frontend/CRUD work on an existing dev server, merely opening or interacting with an existing URL, website audits, or native apps with no local HTTP surface.

- Skill: `adonis0123/local-web-surface` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add adonis0123/local-web-surface`
- Raw SKILL.md: https://api.skillmd.com/api/skills/adonis0123/local-web-surface/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Adonis0123 (https://skillmd.com/u/adonis0123)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/adonis0123/local-web-surface

---


# Local Web Surface

把一个“本地页面”交付成可长期使用的产品入口：固定 `*.localhost` URL、登录后自动可用、可选 `.app` 启动器、真实运行验收。

此 Skill 编排已有代码与系统能力，不规定前端框架。默认优先复用现有 loopback daemon；只有证据证明生命周期、权限或故障域必须隔离时，才新增监听进程。

## 适用性门槛

请求至少要包含一个持久 surface 信号：稳定 `*.localhost` 身份、loopback daemon/gateway、登录自启、共享 listener，或为持久本地 HTTP surface 提供唤醒/打开能力的 macOS `.app` launcher。只包装 `https://...` 公网或内网页面的 `.app` 不属于本 Skill。普通 dev server 上的页面/CRUD 实现不使用本 Skill；只需打开、操作或截图现有 URL 时交给 Browser/`agent-browser`。站点扫描与修复应交给当前环境中已安装且匹配的审计流程；不要硬路由到未验证存在的 Skill。

## 先定义可验证结果

开始前写出以下合同：

```text
Local Web Surface Contract
- Surface: <name>
- Canonical URL: http://<name>.localhost:<port>/
- Data owner: <repo/module>
- Transport owner: <existing daemon or new service>
- Read boundary: <what may be displayed>
- Publish boundary: <none or what may leave the machine>
- Write boundary: <none or exact mutations>
- Access/threat boundary: <trusted local user/processes, or exact authentication/authorization>
- Launcher: <none or App name>
- Done signals: <evidence selected by Change surfaces; Host/handler-only = focused tests + diff scope + curl 200/421/404>
```

若用户已授权自主完成，可从代码和本机状态解析这些值，不重复询问。若选择会改变数据真相源、公开范围、访问/认证边界或占用未知服务端口，先停在一个决策问题上。

## 第一性原理

先从约束推导方案：

1. 页面必须有稳定地址，用户不应记随机端口或手动启动命令。
2. 数据必须只有一个真相源；UI、CLI 与 Agent 不得各写一份副本。
3. 当 `Write boundary` 非 `none` 时，写入必须有一个受控端口；浏览器和 Agent 不直接拼 YAML/JSON。
4. 本地服务只监听 loopback，不能因为“只在自己电脑上”就省略 Host、Origin、输入和路径检查；loopback、Host 与 Origin 都不是调用者身份认证。
5. 多个页面共享同一生命周期时，一个 listener + 精确 Host 分发比多个常驻 daemon 更实用。
6. `.app` 只负责唤醒/打开，不承载业务和第二份服务生命周期。

`.localhost` 及其子域由客户端作为 loopback 特殊域处理；不要为常规 `*.localhost` 别名修改 `/etc/hosts`。实现前仍应以当前系统上的 DNS/curl 结果验证，而不是只凭规范推断。

## DDD 与依赖方向

把职责放回拥有它的 bounded context：

| 边界              | 拥有什么                                                 | 不拥有什么                               |
| ----------------- | -------------------------------------------------------- | ---------------------------------------- |
| Domain/Data owner | 数据模型、校验、所需 repository、内容过滤、HTML/DTO 产物 | 端口、LaunchAgent、全局 Host 表          |
| Local application | 页面 handler、应用路由、用例编排                         | daemon 生命周期、其他 surface 的内部实现 |
| Loopback gateway  | TCP listener、精确 Host 分发、body 上限、响应桥接        | surface 业务规则、数据文件格式           |
| Launcher          | 登录自启、`.app`、打开 canonical URL                     | 第二个 server、数据写入、业务状态        |

依赖方向固定为：gateway 依赖一个窄的 value contract；data owner 不反向依赖 gateway。跨仓库集成时，传递普通 request/response 值或加载一个稳定 handler 入口，不把 domain 类和 storage 细节复制进 gateway。

高内聚体现在：一个数据变更的校验、并发和持久化都留在 repository；低耦合体现在：新增 surface 只增加 Host 映射、handler 入口和 launcher spec，不修改其他 surface 的业务分支。

## Change surfaces（变更面）

先按实际 diff 选择最小验收面，不因 Skill 覆盖完整产品生命周期就默认执行所有检查：

| 变更面             | 典型改动                                                 | 最小必需证据                                                                                          |
| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Host/handler-only  | exact Host 映射、只读 handler、跨 surface 路由           | focused gateway/handler tests、diff scope、`curl` 的 canonical `200`/未知 Host `421`/跨 surface `404` |
| Lifecycle/launcher | listener 启动方式、LaunchAgent、installer、`.app` opener | 加做 plist、tracked PID、单 listener、重复安装和 launcher 参数验收                                    |
| UI                 | 页面结构、交互、响应式                                   | 加做合同相关的真实浏览器、Console、桌面与移动 viewport                                                |
| Write/data         | mutation、repository、文件持久化                         | 加做 schema、ETag、并发、atomic write、临时 fixture 与失败路径                                        |
| Public projection  | local/public catalog 或 sync                             | 加做临时 public sync 与 private/local-only 排除                                                       |

Host/handler-only 仅在现有 listener 与生命周期不变、没有 UI/写入/发布改动时成立；不要为它重装 LaunchAgent、重复查 PID、跑双 viewport 或执行全仓验证。每轮改动后重新看 diff；一旦跨入其他行，就只补对应行的证据。仓库自身明确要求的检查仍然优先。

## 前置检查

编辑前完成：

1. 读取每个涉及仓库的 `AGENTS.md` 和架构/测试文档。
2. 分别记录 `git status --short`；保留现有 dirty changes，禁止重置或顺手整理。
3. 先分类 `Change surfaces`。只有涉及 lifecycle/launcher 时，才用 `lsof -nP -iTCP:<port> -sTCP:LISTEN`、`launchctl print`、进程命令和 launcher 代码确认真实生命周期。
4. 只有依赖或 runner 相关改动时，才从 `package.json`、lockfile 与本机二进制确认 Node、runner、依赖版本。
5. 只查当前变更面会触及的现有 server、handler、CLI、`.app`、LaunchAgent、数据文件、writer 或发布过滤；优先扩展现有模块。
6. 对版本敏感 API 查官方文档或项目锁定版本的文档，并用运行结果验证。

## 设计门禁

在写代码前形成最小 surface 表：

| Surface  | Exact Host                  | Owner         | Reads              | Writes                    |
| -------- | --------------------------- | ------------- | ------------------ | ------------------------- |
| existing | `existing.localhost:<port>` | existing repo | existing data      | existing API              |
| new      | `new.localhost:<port>`      | data repo     | explicit allowlist | explicit commands or none |

必须明确：

- gateway 使用 exact Host allowlist；未知、畸形或错误端口返回 `421`，不能回落到默认管理页。
- 每个 Host 只看见合同列出的路由；不能访问其他 surface 的页面或 API。
- 当 `Read boundary` 涉及公开/私有内容时，必须把“本地可见”和“允许发布”写成两个独立投影；意图不明确或页面可能被他人访问时，本地 reader 默认 fail closed，只展示显式公开内容。
- `private` / `local-only` 只描述“不进入 public projection”，不承诺保密。loopback 服务会替调用方读取文件；同机其他账户或进程可能直接发 HTTP。`Access/threat boundary` 若不信任它们，必须加入真实认证/授权，或停止暴露该内容。
- 仅当 `Write boundary` 非 `none` 时，写请求才至少检查 canonical `Origin`、`Content-Type: application/json`、应用标记 header、body 上限和 `If-Match`。Origin 与应用 header 主要约束浏览器写入，不是客户端身份。
- 页面、CLI 与 Agent 等多个入口读写同一份数据时，统一路由到一个唯一 service/repository writer；禁止绕过它直接编辑。只有无法消除多个 OS 进程直接写入时，才增加跨进程锁。

## 内容型 Reader 的双投影合同（条件）

只在 surface 读取带发布状态的文章或文档时启用本节。不要把 `public` 同时当作本地读取权限和发布资格，除非用户明确要求两者完全相同。

当用户确认这是个人 loopback workspace、`Access/threat boundary` 允许当前本机调用者，并要求本地查看公开与私密成文时：

- Local catalog 只扫描合同指定的文章根，例如 `posts/**/*.md`；接纳显式布尔 `public: true` 与 `public: false`。
- Public projection 或 sync 仍只接纳 `public === true`。不要为满足本地预览去放宽发布脚本。
- 缺失或非布尔 `public` 的记录既不展示也不发布，并只报告相对路径和元数据错误，不返回正文。
- 研究素材、事实底座和助理数据（例如 `raw/`、`notes/`、`digests/`）不属于文章 catalog；只有独立 surface 合同明确纳入时才读取。
- DTO 投影稳定 `visibility: public | private`、目录分类和规范化 tags。页面提供 `all/public/private`、category、tag 与搜索的组合筛选。
- 私密卡片与详情必须明确标记 local-only，并说明 public sync 会排除它，防止“看得见”被误解为“会发布”。

当页面不是个人 loopback workspace、用户没有确认可显示私密内容，或访问边界不能保护敏感内容时，继续 fail closed，仅展示显式 `public: true`。若 `public: false` 代表真正机密而非发布状态，必须先加认证/授权，不能把 `local-only` 标签当作保护。

## TDD 实施顺序

行为变更按 RED → GREEN → REFACTOR：

1. **Domain tests**：覆盖合同中的数据规则；只读 surface 不虚构 mutation 或状态机。
2. **Mutation/concurrency tests（条件）**：仅当 `Write boundary` 非 `none` 时覆盖强 ETag、缺 `If-Match`、stale revision、同进程串行、revision 重检和临时文件清理。只有不可消除的多个 OS 进程会直接写同一文件时，才增加跨进程 lock、崩溃恢复、活 owner 检查和 replacement-lock 测试。
3. **Catalog tests（条件）**：仅当读取发布型内容时分别覆盖 local catalog 与 public projection、稳定 ID、排序、分类/tags/visibility、frontmatter 错误不泄露正文、Markdown XSS、图片 traversal/symlink escape。
4. **Handler tests**：页面 shell、合同列出的用例、跨 surface 404、安全 guard、稳定错误码。
5. **Gateway tests**：Host allowlist、bridge failure closed，以及合同需要的 body limit。
6. **Lifecycle/launcher tests（条件）**：只有对应变更面存在时才覆盖 launcher specs、plist、tracked PID 与重复安装生命周期。
7. **Runtime checks**：只有适用于该合同的上述测试通过后才安装/重启真实服务。

不要先写整套实现再补快照测试。每个发现的运行时 bug 先补能失败的回归测试，再改实现。

## 文件型 CRUD 的最低安全线（条件）

只在 `Write boundary` 非 `none` 且存储为 YAML/JSON 单文件时启用本节。repository 至少做到：

- 在系统边界严格校验根结构、允许字段、值域和真实日期。
- 用内容 hash 生成 strong ETag；所有 mutation 要求 `If-Match`。
- 唯一 writer 内的写操作串行，并在替换前重新检查 revision。优先让 CLI、页面与 Agent 调用这个 writer，避免制造多进程写拓扑。
- 只有证据证明多个 OS 进程必须直接写同一文件时，才使用 crash-recoverable 独占 lock。优先复用成熟的 atomic directory + heartbeat/stale lease 或项目已验证的 advisory-lock，并让所有 writer 使用完全一致的 lock path、stale/update/retry 参数和 compromised 处理。
- 若不得不自建 owner lock，记录随机 token、PID、进程启动身份和时间；活 lease 或身份匹配的 live owner 绝不接管。stale recovery 必须串行，并让所有 writer 在接管窗口前后检查带 owner identity 或 lease/heartbeat 的 recovery guard，防止两个 reaper 删除刚创建的新 lock。
- 在目标文件同目录写唯一 temp file，flush/sync，重新检查 revision，再 atomic rename。
- `finally` 只清理自己 token 对应的 temp/lock；删除前重新核对 owner，绝不删除已被新 owner 替换的 lock。
- 多进程 writer 场景的回归测试必须覆盖：持锁子进程异常退出后 writer 可恢复；live owner 不被接管；旧 owner 清理时不能删除新 owner 的 lock。唯一 writer 场景不虚构跨进程 lock 测试。
- mutation 返回新 revision；冲突返回 `412`，缺前置条件返回 `428`。
- 自动化 CRUD 验收使用临时 fixture。不要为了测试 UI 改写用户真实数据，除非用户明确授权且恢复策略可靠。

## 页面实现

优先交付无构建依赖的响应式页面，除非现有应用已有框架：

- 用户内容进入 DOM 时使用 `textContent`；只有经过受控 sanitizer/安全 Markdown renderer 的 HTML 才可 `innerHTML`。
- 表单和按钮有可访问名称、键盘路径和 `aria-live` 状态。
- mutation 失败保留用户输入；`412` 明确提示 reload/retry。
- 若合同是 Todo CRUD，最少具备 create、list/read、rename/update、complete/reopen、delete 与确认。
- 若合同是内容 Reader，最少具备合同要求的文章列表、搜索、目录分类、标签、可见性筛选、deep link、正文与安全图片；只有合同允许私密内容时才显示 private 状态。
- 同时验收桌面和约 `390x844` 的移动 viewport。

## Gateway 与 launchd

gateway 只监听 `127.0.0.1`（或明确验证过的 loopback），并把传入 header 缩到 handler 实际需要的集合。loopback/Host 隔离不等于认证；按 `Access/threat boundary` 对敏感读取和写入增加真实授权。body 超限时排空请求，不要销毁底层 socket；否则客户端可能收不到稳定 `413`。

macOS 登录服务遵守：

- 一个 `~/Library/LaunchAgents/<label>.plist`，使用 `RunAtLoad` + `KeepAlive`。
- LaunchAgent 必须拥有最终长期 Node listener。优先让 `ProgramArguments` 直接执行 `node --import tsx <absolute-cli.ts> web`；只做环境准备且最后 `exec node ...` 的 shell 也可保持同一 PID。不要让 LaunchAgent 只跟踪会再 spawn 子进程的 `tsx` CLI wrapper，否则 `bootout` 可能留下孤儿 listener。
- `WorkingDirectory`、logs 与 `PATH` 使用绝对值；`plutil -lint` 必须通过。
- 多个 `.app` 是薄 opener：`exec <cli> open-web <surface>`。每个 app 有独立 bundle ID/name，但不启动第二份 daemon。
- installer 必须可重复执行。至少连续安装两次并确认仍只有一个 listener、LaunchAgent `state = running`、error log 无 `EADDRINUSE`。

## 真实验收

先按 `Change surfaces` 选择证据。Host/handler-only 的完整默认验收只有三项：focused gateway/handler tests；`git diff --check` 与范围核对；临时或既有安全 listener 上用 `curl --noproxy '*'` 证明 canonical Host `200`、未知 Host `421`、跨 surface 路由 `404`。这类改动默认不要求 full tests/typecheck/build、LaunchAgent 重装、PID/lsof、浏览器或 public sync。

diff 扩大后，按新增变更面补充以下检查，不机械执行无关项：

1. 所有变更都跑涉及仓库的 focused tests；只有仓库规则要求或 diff 涉及共享类型、构建、依赖时才加 full tests、typecheck/build。单仓库任务不得虚构第二个仓库。
2. `git diff --check`，确认没有范围外文件、secret、临时 lock 或 fixture。
3. Lifecycle/launcher：`plutil -lint` 涉及的 plist；检查每个变更的 `.app/Contents/MacOS/launcher` 的 surface 参数。
4. Lifecycle/launcher：`launchctl print gui/<uid>/<label>` 显示 `state = running` 且有唯一 tracked PID。
5. Lifecycle/launcher：`lsof` 证明目标端口只有一个 loopback listener，且 tracked PID 就是 listener PID，而非短命 wrapper。
6. `curl --noproxy '*'`：每个 canonical Host 返回正确页面/API；未知 Host `421`；跨 surface 路由不泄露；合同包含 body guard 时再证明超大 body 稳定 `413`。
7. UI：用真实浏览器打开变更的 URL，按合同检查 Console、快照与功能；只有合同包含搜索/deep link 或 CRUD 时才验收它们；同时检查桌面与移动 viewport。
8. Public projection：仅对发布型改动跑临时 target sync，证明 `public:false` 与合同列出的其他本地内容没有进入公共产物；若本地 reader 允许私密内容，再用 API 和浏览器分别证明它们确实进入本地列表、详情、搜索/筛选与 deep link。

若 UI CRUD 已由临时 repository/handler 集成测试覆盖，真实浏览器验收可以只读；不要用“为了验收”作为改动用户数据的理由。

## 失败恢复

- `EADDRINUSE`：先解析 tracked PID、listener PID、父子/进程组和完整命令，并搜索应用是否主动 `spawn`/`fork`/daemonize；只终止已验证属于该服务的进程。修 installer 后重复安装验证，不只手工 kill 一次。
- dynamic handler 缺失或响应越界：gateway 返回稳定 `503`，不暴露本地绝对路径和 stack。
- lock timeout：返回可重试错误；按选定协议用 heartbeat/stale lease，或 token、PID 与进程启动身份判断 live/stale。只有 crash-recovery 协议能证明 owner 已失效时才回收；无法证明就保留 lock 并报告，不按未经 heartbeat 约束的文件年龄猜删。
- 浏览器能开但 mutation 失败：检查真实 `Origin`、Host、ETag、header 与 socket 行为，不用关闭安全 guard 取巧。
- local/public catalog 有错误：报告相对路径和元数据问题，不返回文章正文；先核对当前 surface 的 Read boundary 与 Publish boundary，不用删掉私密投影来掩盖发布边界缺失。

## 完成报告

只在真实信号齐全时声明完成：

```text
Local Web Surface Result
- URLs: <canonical URLs>
- Launchers: <none | apps + LaunchAgent when that change surface exists>
- Data ownership: <source of truth + only writer>
- Security boundaries: <Host/Origin/access-auth/local visibility/publication/path controls>
- Verification: <exact evidence selected by Change surfaces>
- User data touched during QA: no | <explicit details>
- UNVERIFIED: none | <blocked check and reason>
```

未经用户授权，不 stage、commit、push、deploy 或改写真实业务数据。

