Skip to content

Commit b5dad3d

Browse files
committed
xinyu-daily 1.1.0: loopback-only routes, JSON-only writes, recover from a corrupt state file
Addresses the review on #27: - Every route checks that Host is loopback and that a browser Origin, when sent, is this listener (same check as cq-viewer); anything else gets 403. Writes require application/json (415). - A truncated, null, or non-object xinyu-state.json is renamed to xinyu-state.broken-<time>.json and the app starts empty with a warn log; top-level fields of the wrong type fall back to defaults. - mutate() edits a copy and swaps it in only after the write succeeds, so a failed write leaves memory and disk in step. - The page sends the JSON content type on every write, including bodyless ones.
1 parent 76543d2 commit b5dad3d

5 files changed

Lines changed: 109 additions & 19 deletions

File tree

‎plugins/yujiachen-y/xinyu-daily/README.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,23 @@ ask the Agent to "打开心屿".
7777
device. Shooting stars and hot-air balloons appear at random times, the birthday cake only on the
7878
user's birthday, and holographic cards only by chance. Sound output was not checked by ear.
7979
Narrow layouts and reduced motion were checked by reading the CSS, not on a device.
80+
- The loopback checks and state recovery were exercised with a Node 22 script: requests with a
81+
forged `Host` or a cross-site `Origin` get `403`, a `text/plain` write gets `415`, and no data
82+
changes; a truncated, `null`, or array state file is renamed aside and the app starts empty, and
83+
fields of the wrong type fall back to defaults; when a disk write fails, the in-memory state
84+
stays as it was. The page's own writes, including those without a body, pass these checks in
85+
headless Chrome.
8086

8187
## Data & access
8288

8389
- Files: reads its own files under `miniapp/client/`. Writes one file, `xinyu-state.json`, inside
84-
the Host-provided `context.dataDir` (atomic write through a temporary file). Nothing else.
90+
the Host-provided `context.dataDir` (atomic write through a temporary file). If that file is
91+
corrupt and cannot be read, it is renamed to `xinyu-state.broken-<time>.json` in the same
92+
directory and the app starts with empty state. Nothing else.
8593
- Network: none from the app itself. The page only calls the app's own same-origin routes under
86-
`/api/`.
94+
`/api/`. The server answers only requests from the local page: `Host` must be `127.0.0.1`,
95+
`localhost`, or `[::1]`, and a browser `Origin`, when sent, must be this app's own address;
96+
anything else gets `403`. Writes accept only `application/json`.
8797
- Processes: none.
8898
- Agent turns: declares `agentAccess: ["session.run"]` with `agentConcurrency: 1`, and runs turns
8999
from the Node entry (`context.agent`) only after you turn on 小屿陪伴, in the mailbox invitation or

‎plugins/yujiachen-y/xinyu-daily/README.zh-CN.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@
3232
- 在 macOS 上的 MiniMax Code 3.1.1-preview.4651 中测试。这是内部预览版,已经包含 3.1.2 的 Mini App Agent API(`context.agent`)。通过宿主的发布流程安装,在右侧面板打开。实测过开场、小屿的自我介绍、“关于你”对话与小岛解锁、今日三问与翻卡、心情贴纸与连续 3 天庆祝、小确幸罐子(放入和摇一摇)、老虎机、答案之书、手账、能量小测、发现秘密与秘密图鉴、白天和夜晚的天空,以及宿主提供 Agent 时设置里会出现陪伴开关。控制台没有报错,也没有失败的请求。
3333
- 小屿陪伴用一个遵循同样 `run` 约定的模拟 Agent 测试过:邀请信(同意、拒绝,以及没有 Agent 时的离线版)、写信和拆信、来信匣、聊天与逐字回复、“记住……”和忘掉便签、心情回应、关闭再打开陪伴、写信失败改放便条和“请小屿重写”、Agent 开始调用工具时取消本轮、超时、每日上限、会话复用与更换,以及关心提示。
3434
- 未完整实测:小屿陪伴还没有接真实模型在真机上跑过完整流程。流星和热气球随机出现;生日蛋糕只在生日当天出现;闪卡全凭运气。音效没有人耳验证。窄屏布局和减弱动态效果是通过阅读 CSS 检查的,没有在真机上验证。
35+
- 本机请求检查和存档容错用 Node 22 脚本验证过:伪造 `Host`、跨站 `Origin` 的请求返回 403,`text/plain` 的写请求返回 415,数据都没有变化;状态文件被截断、内容是 `null` 或数组时,会改名备份并从空状态启动,字段类型不对时换回默认值;写盘失败时,内存里的状态保持不变。页面自己的写请求(包括不带正文的)在 headless Chrome 里都能通过这些检查。
3536

3637
## Data & access
3738

38-
- 文件:只读取自身 `miniapp/client/` 下的文件;只写入一个文件,即宿主提供的 `context.dataDir` 里的 `xinyu-state.json`(先写临时文件再原子替换)。
39-
- 网络:心屿自身不发请求。页面只调用本应用同源的 `/api/` 路由。
39+
- 文件:只读取自身 `miniapp/client/` 下的文件;只写入一个文件,即宿主提供的 `context.dataDir` 里的 `xinyu-state.json`(先写临时文件再原子替换)。如果这个文件损坏、读不出来,会在同一目录里改名为 `xinyu-state.broken-<时间>.json` 留底,然后从空状态启动。
40+
- 网络:心屿自身不发请求。页面只调用本应用同源的 `/api/` 路由。服务端只接受本机页面的请求:`Host` 必须是 `127.0.0.1`、`localhost` 或 `[::1]`,浏览器带了 `Origin` 时必须是本应用自己的地址,否则返回 403;写请求只接受 `application/json`。
4041
- 进程:不启动任何子进程。
4142
- Agent:声明了 `agentAccess: ["session.run"]` 和 `agentConcurrency: 1`。只有在你通过邀请信或设置打开“小屿陪伴”后,才会从 Node 端(`context.agent`)运行 Agent,而且只要求纯文本,共三种:今天的信(外加最多 8 条小屿记下的关于你的短笔记)、聊天回复、对今天心情贴纸的一句话。每次的提示词由应用自己的指令和标注为数据的记录组成:今天的日期、昵称、连续打卡天数、距离生日的天数(不含生日日期)、最近的心情和备注、小确幸、“关于你”的回答、能量风格、今日灵感卡关键词、最近问过答案之书的问题、最近的聊天、记事本,以及上一封信。你忘掉的笔记不会再发送。同一天的请求复用一个会话,这些会话不会出现在你的会话列表里。
4243
- 不用工具、不做审批:如果某一轮开始调用工具、申请权限或者向你提问,应用会直接取消这一轮,从不回复 `allowOnce`,小屿改放手写便条或回一句抱歉。取消是在收到 `tool.started` 事件时发出的,执行很快的只读工具可能在取消前已经执行完。

‎plugins/yujiachen-y/xinyu-daily/miniapp/client/app.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,8 @@ const isShiny = (c) => c.energy >= 90 || hashStr(`${c.date}|${c.keyword}`) % 8 =
534534
async function api(method, path, body) {
535535
const res = await fetch(path, {
536536
method,
537-
headers: body ? { 'content-type': 'application/json' } : {},
537+
// 写请求一律带 JSON 头(服务端只收 JSON),不带正文的也一样
538+
headers: method === 'GET' ? {} : { 'content-type': 'application/json' },
538539
body: body ? JSON.stringify(body) : undefined,
539540
});
540541
let data = null;

‎plugins/yujiachen-y/xinyu-daily/miniapp/node/domain.mjs‎

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ function emptyState() {
9494
};
9595
}
9696

97+
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
98+
99+
/** 读回存档:缺的字段补默认值;顶层集合类型不对的换成空值,免得一个字段坏了整个应用打不开。 */
100+
function restoreState(raw) {
101+
const base = emptyState();
102+
const state = { ...base, ...raw, profile: { ...base.profile, ...(isPlainObject(raw.profile) ? raw.profile : {}) } };
103+
for (const [key, value] of Object.entries(base)) {
104+
if (Array.isArray(value) ? !Array.isArray(state[key]) : isPlainObject(value) && !isPlainObject(state[key])) {
105+
state[key] = value;
106+
}
107+
}
108+
state.companion = normalizeCompanion(raw.companion);
109+
return state;
110+
}
111+
97112
/** 本机本地日期 YYYY-MM-DD */
98113
export function localDate(d = new Date()) {
99114
const y = d.getFullYear();
@@ -211,37 +226,59 @@ function str(v, field, max, { required = true } = {}) {
211226
}
212227

213228
/**
214-
* @param {{dataDir:string, onChange:(version:number)=>void}} opts
229+
* @param {{dataDir:string, onChange:(version:number)=>void, onWarn?:(message:string)=>void}} opts
215230
*/
216-
export async function createDomain({ dataDir, onChange }) {
231+
export async function createDomain({ dataDir, onChange, onWarn = () => {} }) {
217232
const file = join(dataDir, 'xinyu-state.json');
218233
await mkdir(dataDir, { recursive: true });
219234
let state = emptyState();
235+
/** @type {unknown} */
236+
let raw;
237+
let broken = '';
220238
try {
221-
const raw = JSON.parse(await readFile(file, 'utf8'));
222-
state = { ...emptyState(), ...raw, profile: { ...emptyState().profile, ...(raw.profile ?? {}) } };
223-
if (!state.about || typeof state.about !== 'object') state.about = {};
224-
if (!state.secrets || typeof state.secrets !== 'object') state.secrets = {};
225-
if (!state.flags || typeof state.flags !== 'object') state.flags = {};
226-
state.companion = normalizeCompanion(raw.companion);
239+
raw = JSON.parse(await readFile(file, 'utf8'));
240+
if (!isPlainObject(raw)) broken = 'shape';
227241
} catch (error) {
228-
if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error;
242+
if (error instanceof SyntaxError) broken = 'syntax';
243+
else if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error;
244+
}
245+
if (broken) {
246+
// 文件写坏了(截断的 JSON、null 之类):原样改名留底,从空状态启动,免得应用再也打不开
247+
const backup = `xinyu-state.broken-${new Date().toISOString().replace(/[-:.]/g, '')}.json`;
248+
await rename(file, join(dataDir, backup));
249+
onWarn(`xinyu.state.recovered reason=${broken} backup=${backup}`);
250+
} else if (raw) {
251+
state = restoreState(/** @type {Record<string, any>} */ (raw));
229252
}
230253
let version = 0;
231254
let epoch = 0;
232255
let queue = Promise.resolve();
233256

234-
async function persist() {
257+
/** @param {object} data */
258+
async function persist(data) {
235259
const tmp = `${file}.${process.pid}.tmp`;
236-
await writeFile(tmp, JSON.stringify(state), 'utf8');
260+
await writeFile(tmp, JSON.stringify(data), 'utf8');
237261
await rename(tmp, file);
238262
}
239263

240-
/** 串行化所有写操作:修改 → 落盘 → 广播 */
264+
/**
265+
* 串行化所有写操作:在副本上修改 → 落盘 → 换上副本 → 广播。
266+
* 落盘失败(或 fn 抛错)时内存保持原样,和磁盘一致。fn 必须是同步的,里面通过 state 读写。
267+
*/
241268
function mutate(fn) {
242269
const run = queue.then(async () => {
243-
const result = fn();
244-
await persist();
270+
const current = state;
271+
state = structuredClone(current);
272+
let result;
273+
let next;
274+
try {
275+
result = fn();
276+
next = state;
277+
} finally {
278+
state = current; // 落盘成功之前,别处读到的都还是旧状态
279+
}
280+
await persist(next);
281+
state = next;
245282
version++;
246283
try { onChange(version); } catch { /* 广播失败不回滚 */ }
247284
return result;

‎plugins/yujiachen-y/xinyu-daily/miniapp/node/server.mjs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const failure = (code) => Object.assign(new Error(code), { code });
4141
const codeOf = (error) => (error && typeof error === 'object' && 'code' in error ? String(error.code) : 'UNKNOWN');
4242
/** logger 的 fields 只有键名会离开进程,诊断值要写进消息;只放枚举形态的短标识,不带任何文本。 */
4343
const logToken = (v) => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(v) ? v : 'OTHER');
44+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
4445

4546
/**
4647
* @param {MiniAppContext} context
@@ -62,6 +63,7 @@ export async function start(context) {
6263
const payload = `event: changed\ndata: ${JSON.stringify({ version })}\n\n`;
6364
for (const res of sseClients) res.write(payload);
6465
},
66+
onWarn: (message) => context.logger.warn(message),
6567
});
6668

6769
const heartbeat = setInterval(() => {
@@ -302,9 +304,20 @@ export async function start(context) {
302304
* @param {import('node:http').ServerResponse} response
303305
*/
304306
async function handle(request, response) {
307+
// 每个路由都只接受本机心屿页面的请求:Host 必须是回环地址(挡住 DNS rebinding),浏览器带了 Origin
308+
// 就必须是这个监听地址本身。3.1.2 起宿主会给端口加凭据;更早的客户端没有那层保护,靠这里兜住。
309+
if (!isLoopbackRequest(request, context.listen.port)) {
310+
request.resume();
311+
return sendJson(response, 403, { error: 'forbidden', message: '只接受本机心屿页面的请求' });
312+
}
305313
const url = new URL(request.url ?? '/', 'http://miniapp.local');
306314
const path = url.pathname;
307315
const method = request.method ?? 'GET';
316+
// 写请求只收 JSON:跨站表单和 text/plain 这类不经预检的请求进不了处理函数
317+
if (method !== 'GET' && method !== 'HEAD' && !String(request.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
318+
request.resume();
319+
return sendJson(response, 415, { error: 'json_required', message: '请求格式不正确' });
320+
}
308321

309322
if (method === 'GET' && path in STATIC) {
310323
const [name, type] = STATIC[/** @type {keyof typeof STATIC} */ (path)];
@@ -436,6 +449,34 @@ function sendJson(res, status, body) {
436449
res.end(JSON.stringify(body));
437450
}
438451

452+
/**
453+
* Host 必须是本机;浏览器带了 Origin 时,必须是 http://<回环地址>:<本应用端口>。
454+
* 不带 Origin 的请求(同源 GET、本机的非浏览器客户端)只看 Host。
455+
* @param {import('node:http').IncomingMessage} request @param {number} port
456+
*/
457+
function isLoopbackRequest(request, port) {
458+
const host = request.headers.host;
459+
if (host !== undefined && !LOOPBACK_HOSTS.has(hostnameOf(host))) return false;
460+
const origin = request.headers.origin;
461+
if (origin === undefined) return true;
462+
let url;
463+
try {
464+
url = new URL(origin);
465+
} catch {
466+
return false; // 包括 "null"(沙箱 iframe、file://)
467+
}
468+
return url.protocol === 'http:' && LOOPBACK_HOSTS.has(hostnameOf(url.host)) && Number(url.port || 80) === port;
469+
}
470+
471+
/** 去掉端口,IPv6 去掉方括号:'[::1]:8080' -> '::1'。 @param {string} hostHeader */
472+
function hostnameOf(hostHeader) {
473+
const h = hostHeader.trim().toLowerCase();
474+
const bracketed = /^\[([^\]]*)\]/.exec(h);
475+
if (bracketed) return bracketed[1];
476+
const colon = h.lastIndexOf(':');
477+
return colon === -1 ? h : h.slice(0, colon);
478+
}
479+
439480
/** @param {import('node:http').IncomingMessage} req */
440481
async function readJson(req) {
441482
let size = 0;

0 commit comments

Comments
 (0)