# Kiến trúc

> Processes, plugin isolation and the reasoning behind the split.

# Kiến trúc / Architecture

## Tiếng Việt

Trang này trả lời **"vì sao chia như vậy"**, không phải "code nằm ở đâu".

### 1. Các tiến trình và ranh giới

| Vùng | Tiến trình | Vai trò |
|---|---|---|
| main (Node, Electron) | Main | Cửa sổ, vòng đời plugin, IPC, hai HTTP server cục bộ, cloud client |
| renderer (Svelte 5) | Renderer | Overlay, Settings, Launcher, Web Dashboard, cửa sổ pop-out |
| Plugin UI | `<webview>` (desktop) hoặc `<iframe>` (dashboard) | Mỗi plugin một khung riêng |
| Plugin backend | Main | Chỉ plugin có `mainEntry`; `activate()` / `deactivate()` |
| Dịch vụ đám mây | Cloudflare Worker | Đăng nhập, thiết bị, giấy phép, relay nhiều máy |
| Kazuo Hub | App Electron riêng | Cài và cập nhật Kazuo AI |

### 2. Một nguồn chân lý cho mỗi việc

Ba quyết định cùng một mô-típ, và đều sinh ra **sau khi sự cố lệch đã xảy ra thật**: điều khiển plugin (cài/gỡ/bật/tắt/pop-out) đi qua **một** module thay vì năm cổng tự viết; API `window['kazuo-ai']` mà plugin dùng có **một** lõi bridge dùng chung cho desktop lẫn web; gửi tin tới mọi cửa sổ có **một** hàm thay vì sáu vòng lặp tự chép. Bài học: cùng một nghiệp vụ viết ở nhiều nơi không sai ngay, chúng **trôi dần ra xa nhau** cho tới khi người dùng thấy hành vi khác nhau ở hai chỗ.

### 3. Một plugin hỏng không được kéo sập phần còn lại

Hàm bật plugin **không bao giờ reject**: vòng khởi động chạy tuần tự, nên một `manifest.json` hỏng từng làm mọi plugin xếp sau không bao giờ được bật. Bốn chi tiết bắt buộc: khởi động trên microtask; giải phóng slot ở cả hai nhánh và bọc cả `onError`; chờ 10 giây rồi thôi nhưng **không huỷ** (lần chạy đầu có thể cài npm hoặc tải Python mất hàng phút); kiểm tra lại sau khi `activate()` xong vì người dùng có thể đã tắt plugin trong lúc nó đang khởi động.

### 4. Vì sao hai HTTP server cục bộ, không phải một

| | Bridge server | Dashboard server |
|---|---|---|
| Phục vụ ai | Công cụ dev / MCP trên chính máy này | Trình duyệt, kể cả máy khác trong LAN |
| Bind | Loopback | Mọi giao diện mạng (cho WSL, máy khác, tunnel) |
| Luật Origin | Từ chối mọi request có header `Origin` | Phải chấp nhận trình duyệt |
| Xác thực | Không cần | Token bắt buộc cho mọi route API, cả GET; WebSocket dùng vé sống 60 giây |

Hai server có **threat model ngược nhau**, nên gộp làm một sẽ buộc chọn luật lỏng hơn cho cả hai. Luật từ chối mọi `Origin` của bridge chống một vector cụ thể: trang web độc trong trình duyệt của nạn nhân gọi tới loopback; CORS không cứu được vì CORS chỉ chặn đọc phản hồi, còn ở đây chỉ cần gửi được request là đã có hậu quả. Phía dashboard, chặn theo Origin là không đủ vì client không phải trình duyệt giả được header; token mới là thứ bảo vệ thật.

### 5. Cái gì tin được, cái gì không

- `pluginId` do client tự khai **không phải danh tính**: đường WebSocket mà mọi widget dùng từng trả nguyên settings của plugin khác. Nay settings đi qua lớp lọc.
- **Lọc bí mật theo kiểu dữ liệu, không theo tên key**: khớp theo tên từng biến cờ boolean thành falsy và tắt luôn yêu cầu xác thực. Bí mật luôn là chuỗi, nên chỉ lọc chuỗi.
- **Danh tính người phát hành plugin lấy từ tên file index**, không từ trường `author` tự khai. Danh sách người phát hành fail closed: file hỏng thì thu hẹp về một tên.
- **Mọi dữ liệu đọc từ registry là đầu vào không tin được**: id từ registry không được ghép thẳng vào đường dẫn, file nén không được tạo symlink trỏ ra ngoài, so khớp tiền tố phải có dấu phân cách thư mục.

### 6. Desktop và Web Dashboard dùng chung khung widget

Khung widget (kéo, resize, header, nút) nằm trong một component dùng chung, **không biết** bên trong là `<webview>` hay `<iframe>`. Khác biệt còn lại là có chủ đích: overlay trong suốt, dashboard nền đặc; dashboard cố tình hiển thị cả plugin chế độ cửa sổ vì trình duyệt không có cửa sổ hệ điều hành để bàn giao. Hai quyết định chống điều kiện tranh chấp: trạng thái phóng to không được lưu (chuỗi lưu → phát tin → nạp lại trên dashboard độ trễ cao có thể về lệch thứ tự); bộ đếm yêu cầu focus là biến thường, không phải trạng thái phản ứng.

### 7. Nhiều máy: relay qua Cloudflare

Mỗi thiết bị có một Durable Object làm điểm hẹn. Nguyên tắc: **token của máy không bao giờ rời khỏi máy**; trình duyệt định danh bằng token tài khoản, kiểm tại Worker trước khi chạm tới Durable Object; vé WebSocket bị gỡ khỏi URL trước khi chuyển tiếp. Chỉ một số tiền tố đường dẫn được tunnel; vỏ ứng dụng phục vụ tĩnh. Hai chi tiết phản trực giác: lỗi 401 từ relay không có nghĩa token hết hạn (có nhiều nguyên nhân cùng trả 401, agent phải hỏi lại server); đồng hồ máy lệch không được phép đăng xuất ai, server luôn là bên quyết định.

### 8. Cấu hình nền tảng tách khỏi mã nguồn

Mọi hằng số backend (tên miền, khoá đọc store, OAuth) gom vào hồ sơ cấu hình chọn lúc build, để người fork trỏ **cả hai app** về hạ tầng riêng bằng cách sửa một file. Các giá trị nằm trong bộ cài gửi tới người dùng vốn không thể giữ kín; yêu cầu thật chỉ là giữ khỏi git.

### 9. Khoá phiên khi nhiều agent cùng làm việc

Nhiều phiên AI song song dùng chung một thư mục làm việc, một tiến trình Electron, một thư mục build. Hook của công cụ AI ghi khoá (chỉ hook biết mã phiên), và hook **fail-open**: đây là công cụ điều phối chống nhầm lẫn, không phải hàng rào bảo mật, nên một guard hỏng không được làm tê liệt mọi phiên.

## English

This page explains **why the system is split the way it is**, not where the code lives.

### 1. Processes and boundaries

| Area | Process | Role |
|---|---|---|
| main (Node, Electron) | Main | Windows, plugin lifecycle, IPC, two local HTTP servers, cloud client |
| renderer (Svelte 5) | Renderer | Overlay, Settings, Launcher, Web Dashboard, pop-out windows |
| Plugin UI | `<webview>` (desktop) or `<iframe>` (dashboard) | One frame per plugin |
| Plugin backend | Main | Only plugins with `mainEntry`; `activate()` / `deactivate()` |
| Cloud services | Cloudflare Worker | Sign-in, devices, licensing, multi-machine relay |
| Kazuo Hub | Separate Electron app | Installs and updates Kazuo AI |

### 2. One source of truth per concern

Three decisions share a pattern, and each was made **after a real drift incident**: plugin control (install/uninstall/toggle/pop-out) goes through **one** module instead of five hand-written gateways; the `window['kazuo-ai']` API that plugins use has **one** bridge core shared by desktop and web; broadcasting to every window is **one** function instead of six copied loops. The lesson: the same logic written in several places is not wrong at first, it **drifts apart** until users see different behaviour in two places.

### 3. A broken plugin must not take the rest down

Plugin activation **never rejects**: the startup loop is sequential, so one broken `manifest.json` once prevented every later plugin from starting. Four mandatory details: start on a microtask; release the slot on both branches and wrap `onError` too; wait 10 seconds then move on but **never cancel** (a first run may legitimately install npm packages or download Python for minutes); re-check after `activate()` returns because the user may have disabled the plugin meanwhile.

### 4. Why two local HTTP servers, not one

| | Bridge server | Dashboard server |
|---|---|---|
| Serves | Dev tools / MCP on this machine | Browsers, including other machines on the LAN |
| Bind | Loopback | All interfaces (for WSL, other machines, tunnels) |
| Origin rule | Rejects every request carrying an `Origin` header | Must accept browsers |
| Auth | None needed | Token required on every API route, GET included; WebSocket uses a 60-second ticket |

The two servers have **opposite threat models**, so merging them would force the weaker rule on both. The bridge's reject-any-Origin rule defeats one specific vector: a malicious page in the victim's browser calling loopback; CORS does not help because CORS only blocks reading the response, while here merely sending the request has consequences. On the dashboard side, Origin checks are not enough because non-browser clients can forge the header; the token is the real protection.

### 5. What to trust

- A client-supplied `pluginId` is **not an identity**: the WebSocket path every widget uses once returned another plugin's raw settings. Settings now pass through a filter.
- **Filter secrets by value type, not key name**: name matching turned boolean flags into falsy values and silently disabled authentication. Secrets are always strings, so only strings are filtered.
- **Plugin publisher identity comes from the index file name**, not from a self-declared `author` field. The publisher list fails closed: a broken file narrows it to one name.
- **Everything read from the registry is untrusted input**: registry ids are never joined straight into paths, archives may not create symlinks pointing outside, prefix checks must include the directory separator.

### 6. Desktop and Web Dashboard share the widget frame

The frame (drag, resize, header, buttons) is one shared component that **does not know** whether it hosts a `<webview>` or an `<iframe>`. Remaining differences are deliberate: the overlay is transparent, the dashboard opaque; the dashboard intentionally shows window-mode plugins too, because a browser has no OS window to hand over to. Two race-condition decisions: maximised state is not persisted (the save → broadcast → reload chain can arrive out of order on a high-latency dashboard); the focus-request counter is a plain variable, not reactive state.

### 7. Many machines: relay through Cloudflare

Each device gets a Durable Object as its meeting point. Rule: **the machine's token never leaves the machine**; the browser identifies with an account token checked at the Worker before anything touches the Durable Object; the WebSocket ticket is stripped from the URL before forwarding. Only a few path prefixes are tunnelled; the app shell is served statically. Two counter-intuitive details: a 401 from the relay does not mean the token expired (several causes share that status, the agent must ask the server); a skewed clock must never log anyone out, the server always decides.

### 8. Platform configuration outside the source

Every backend constant (domains, store read key, OAuth) lives in a configuration profile chosen at build time, so a fork can point **both apps** at its own infrastructure by editing one file. Values shipped inside an installer cannot be kept secret from users anyway; the real requirement is keeping them out of git.

### 9. Session locks when several agents work at once

Several AI sessions share one working tree, one Electron process and one build directory. A hook of the AI tooling writes the lock (only the hook knows the session id), and the hook **fails open**: it is a coordination aid against mistakes, not a security barrier, so a broken guard must never paralyse every session.
