diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..03e206f --- /dev/null +++ b/README.en.md @@ -0,0 +1,140 @@ +# WeChatPilot + +[简体中文](README.md) | [English](README.en.md) + +**Local-first, outbound-only, auditable AI automation for WeChat.** + +Let local AI automation reach WeChat safely — without letting WeChat control your computer. + +![WeChatPilot offline demo preview](docs/assets/demo-preview.png) + +Current release: **Public Preview `v0.1.0-beta.1`**. This is not Stable and not Production Ready. Until the seven-day stability review (Issue #4) is complete, do not claim the pipeline is already stable. + +> WeChatPilot is not affiliated with, authorized by, or endorsed by Tencent or WeChat. + +## Product positioning + +WeChatPilot is not a general remote-control WeChat bot. It is a local-first delivery tool for scheduled AI automation results: + +- The work runs on your Mac. +- Delivery is outbound-only to the owner-bound WeChat account that scanned the QR code. +- WeChat cannot start jobs, choose recipients, open a shell, or remote-control Codex. +- Delivery state is auditable; indeterminate sends fail closed instead of auto-replaying. + +The first built-in workflow is a daily tech digest. + +## Offline demo + +```sh +npm ci +npm run build +npm run wechatpilot -- demo +``` + +The demo: + +- does not access WeChat +- does not call Codex +- does not read private `.state` credentials +- uses fixed fixtures +- writes a sample digest and preview image under `output/demo/` +- prints safe next steps + +## Supported environment + +- macOS +- Node.js 22 +- Official Codex CLI available on `PATH` and logged in +- Simplified Chinese and `Asia/Shanghai` for v0.1 +- Personal WeChat account for owner-bound outbound delivery + +## Quick start + +```sh +npm ci +npm run build +npm run wechatpilot -- init +npm run wechatpilot -- doctor +./scripts/init-wechat.sh +npm run send:test +``` + +Common commands: + +```sh +npm run wechatpilot -- demo +npm run wechatpilot -- doctor --repair +npm run wechatpilot -- config validate +npm run wechatpilot -- run daily-tech-digest --dry-run +npm run wechatpilot -- schedule install --hour 8 --minute 0 +npm run wechatpilot -- schedule status +npm run wechatpilot -- schedule remove +``` + +v0.1 scheduling is fixed to `08:00 Asia/Shanghai`. The host system timezone must also be `Asia/Shanghai` because LaunchAgent calendar triggers use the machine timezone. + +## Security boundary + +| Capability | Typical remote-control bot | WeChatPilot | +| --- | ---: | ---: | +| WeChat can trigger host commands | often possible | explicitly forbidden | +| Arbitrary recipients | often possible | owner-bound only | +| Auto-retry when send is indeterminate | may duplicate | stop by default | +| Credentials stay local | maybe | yes | +| Run/delivery ledger | maybe | yes | + +Additional guarantees: + +- Project-owned minimal WeChat transport only. +- No upstream remote-control entrypoint, router, adapter registry, or `undici` production dependency. +- Codex child stays ephemeral, read-only, non-interactive, stdin-only. +- Secrets, tokens, cookies, QR codes, and personal paths must stay out of Issues and logs. + +See also: + +- `docs/security/runtime-closure.md` +- `security/openvex.json` +- `SECURITY.md` +- `THREAT_MODEL.md` + +## Privacy model + +Local-first means scheduling, credentials, ledgers, locks, and audit state stay on the machine. It does **not** mean model inference is fully offline. Candidate titles, summaries, and public URLs may enter the Codex child process. WeChat tokens, context tokens, and user IDs must not. + +Details: [PRIVACY.md](PRIVACY.md) and [DATA_FLOW.md](DATA_FLOW.md). + +## Known limitations + +- macOS only +- Node.js 22 and logged-in Codex CLI required +- v0.1 locked to Simplified Chinese and `Asia/Shanghai` +- outbound delivery only to the bound owner account +- no inbound WeChat commands, remote shell, or Codex remote control +- only one built-in workflow: daily tech digest +- Public Preview, not Stable +- Issue #4 seven-day stability evidence is still open + +## Contribution guide + +Thanks for helping improve WeChatPilot. + +- Keep WeChat one-way. Do not add inbound command execution or remote control. +- Prefer small pull requests with tests for the changed boundary. +- Do not commit secrets, tokens, cookies, QR codes, private digests, real user IDs, or local absolute paths. +- Start with: + +```sh +npm ci +npm test +npm run typecheck +npm run build +npm run wechatpilot -- config validate +``` + +Full contributor guidance: [CONTRIBUTING.md](CONTRIBUTING.md) +Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) +Security reporting: [SECURITY.md](SECURITY.md) + +## License + +MIT for WeChatPilot-owned code. Third-party notices: `THIRD_PARTY_NOTICES.md` and `package-lock.json`. diff --git a/README.md b/README.md index 2c5f9cf..70a81ee 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,50 @@ # WeChatPilot -WeChatPilot 是一个本地优先、只向本人微信单向投递的自动化项目。当前内置工作流是“每日科技情报”。微信不能执行命令、控制 Codex 或选择收件人;凭据与投递状态只保存在被 Git 忽略且权限受限的 `.state/`。 +[简体中文](README.md) | [English](README.en.md) -> WeChatPilot 与微信、腾讯不存在隶属、授权或背书关系。 +**让本地 AI 自动化安全地抵达微信,而不是让微信反向控制电脑。** -## 安装 +> Local-first, outbound-only, auditable AI automation for WeChat. -要求 macOS、Node.js 22,以及已登录且可从 `PATH` 找到的官方 Codex CLI。仓库不保存个人路径,不提交可直接加载的部署 plist,也不要求固定 Homebrew 目录。 +![WeChatPilot offline demo preview](docs/assets/demo-preview.png) -普通用户: +当前版本:**Public Preview `v0.1.0-beta.1`**。这不是 Stable,也不是 Production Ready。七日稳定性复盘(Issue #4)完成前,不得宣称链路已经稳定。 + +✅ 本地优先 +✅ 仅向扫码绑定的本人微信单向发送 +✅ 不开放远程命令、Shell 或 Codex 远程控制 +✅ 发送结果可审计;无法确认时拒绝自动重放 + +> WeChatPilot 与微信、腾讯不存在隶属、授权或背书关系。请勿把本项目理解为官方产品。 + +## 5 分钟快速开始 + +要求:macOS、Node.js 22、已登录且可从 `PATH` 找到的官方 Codex CLI。 ```sh npm ci npm run build +npm run wechatpilot -- demo +``` + +`demo` 完全离线:不访问微信、不调用 Codex、不读取私人 `.state` 凭据。它使用固定 Fixture,在本地生成示例日报文本和预览图,方便你先看最终效果。 + +完整安装: + +```sh npm run wechatpilot -- init npm run wechatpilot -- doctor ./scripts/init-wechat.sh +npm run send:test ``` -贡献者使用 `./scripts/init-project.sh` 安装依赖、执行测试、类型检查和构建;它与普通用户初始化分开。 - 统一命令: ```sh npm run wechatpilot -- init npm run wechatpilot -- doctor npm run wechatpilot -- doctor --repair +npm run wechatpilot -- demo npm run wechatpilot -- config validate npm run wechatpilot -- run daily-tech-digest --dry-run npm run wechatpilot -- schedule install --hour 8 --minute 0 @@ -35,6 +54,34 @@ npm run wechatpilot -- schedule remove `schedule install` 在安装时探测 Node/Codex,生成当前用户专属 plist。v0.1 固定使用简体中文、`Asia/Shanghai`、每日 08:00 与固定栏目配额;LaunchAgent 日历触发使用机器系统时区,因此运行机器也必须设为 `Asia/Shanghai`。`doctor` 默认输出人类可读结果(`--json` 保留机器格式),且默认只读;只有显式 `--repair` 才会删除已确认进程死亡、inode 未变化且未被 `sending/indeterminate` ledger 阻止的陈旧锁。 +## 它解决什么问题 + +很多“微信 Bot”会把聊天入口变成电脑控制面。WeChatPilot 反过来: + +1. 本机任务先跑完。 +2. 结果经排序、生成、链接校验和 ledger。 +3. 只向扫码绑定的本人微信单向投递。 +4. 微信不能启动任务、不能选择收件人、不能执行 Shell。 + +当前唯一内置工作流:**每日科技情报**。 + +## 安全边界 + +| 能力 | 普通远程控制 Bot | WeChatPilot | +| --- | ---: | ---: | +| 微信触发电脑命令 | 可能支持 | 明确禁止 | +| 任意收件人 | 可能支持 | 仅绑定本人 | +| 不确定发送自动重试 | 可能重复发送 | 默认停止 | +| 凭据本地保存 | 不一定 | 是 | +| 运行与投递 Ledger | 不一定 | 是 | + +更多细节: + +- 配置模型:`UserConfig` / `SecurityProfile` / `ResolvedRuntime` +- 微信 transport 与供应链:`src/transport/wechat-transport.ts`、`docs/security/runtime-closure.md`、`security/openvex.json` +- 隐私与数据流:`PRIVACY.md`、`DATA_FLOW.md` +- 威胁模型:`THREAT_MODEL.md` + ## 配置模型 - `UserConfig`:`config/sources.yaml`、`ranking.yaml`、`digest.yaml` 与 observer 的来源和通知策略;v0.1 的时区、语言、配额、栏目和篇幅为固定契约。 @@ -45,12 +92,7 @@ npm run wechatpilot -- schedule remove ## 微信 transport 与供应链 -项目不再携带 `cli-in-wechat` submodule,也不安装其 `undici` 或开发依赖。最小 transport 位于 `src/transport/wechat-transport.ts`,只实现扫码登录、重试请求和主动发送所需能力。运行时闭包、失效条件和机器可读 OpenVEX 见: - -- `docs/security/runtime-closure.md` -- `security/openvex.json` - -CI 会执行生产依赖审计,并禁止重新引入上游主入口、Router、适配器注册表、代理初始化器、`ProxyAgent` 或 `undici`。 +项目不再携带 `cli-in-wechat` submodule,也不安装其 `undici` 或开发依赖。最小 transport 位于 `src/transport/wechat-transport.ts`,只实现扫码登录、重试请求和主动发送所需能力。CI 会执行生产依赖审计,并禁止重新引入上游主入口、Router、适配器注册表、代理初始化器、`ProxyAgent` 或 `undici`。 ## 正确性与网络安全 @@ -60,7 +102,7 @@ CI 会执行生产依赖审计,并禁止重新引入上游主入口、Router ## 隐私与数据流 -“本地优先”指调度、凭据、ledger、锁和审计在本地,不代表模型推理完全离线。候选标题、摘要和公开 URL 会进入 Codex 子进程;微信 Token、context token 和用户 ID 不会进入模型。详见 [PRIVACY.md](PRIVACY.md) 与 [DATA_FLOW.md](DATA_FLOW.md)。 +“本地优先”指调度、凭据、ledger、锁和审计在本地,不代表模型推理完全离线。候选标题、摘要和公开 URL 会进入 Codex 子进程;微信 Token、context token 和用户 ID 不会进入模型。 ## 目录布局 @@ -70,12 +112,20 @@ CI 会执行生产依赖审计,并禁止重新引入上游主入口、Router 兼容默认仍落在项目目录;可通过 profile root / 环境变量拆分。详见 [ARCHITECTURE.md](ARCHITECTURE.md)。 +## 已知限制 + +- 仅支持 macOS。 +- 需要 Node.js 22 与已登录 Codex CLI。 +- v0.1 固定简体中文与 `Asia/Shanghai`。 +- 仅向扫码绑定的本人微信单向投递。 +- 不提供入站命令、远程 Shell 或 Codex 远程控制。 +- 当前只有“每日科技情报”一个内置工作流。 +- Public Preview,不是 Stable。 + ## 稳定性门禁 平台抽象 Issue #1 明确依赖七天稳定性复盘 Issue #4。复盘只允许输出脱敏计数和耗时分布,不包含正文、路径、身份或凭据。未收集满七个自然日时,项目不得宣称链路“已经稳定”,也不得开始通用平台抽象。 -至少记录:每日成功/失败/跳过、来源降级、Codex 重写、链接替换与门禁失败、微信确定失败与不确定发送、重复拦截、陈旧锁与人工恢复、日报生成和投递耗时。 - ## 常用命令 ```sh @@ -84,6 +134,7 @@ npm run typecheck npm run build npm run audit:runtime npm run check:transport-boundary +npm run wechatpilot -- demo ./scripts/generate-digest.sh --dry-run ./scripts/send-test.sh ./scripts/send-digest.sh --date YYYY-MM-DD @@ -92,3 +143,5 @@ npm run check:transport-boundary 日志会递归脱敏。请勿在公开 Issue 提交 Token、Cookie、微信用户 ID、context token、二维码、私有正文、日志或本机路径。漏洞请使用 GitHub 私密安全报告。 WeChatPilot 自有代码采用 MIT License;第三方依赖见 `THIRD_PARTY_NOTICES.md` 与 `package-lock.json`。 + +贡献指南见 [CONTRIBUTING.md](CONTRIBUTING.md)。英文说明见 [README.en.md](README.en.md)。 diff --git a/docs/assets/demo-preview.png b/docs/assets/demo-preview.png new file mode 100644 index 0000000..fadc3b7 Binary files /dev/null and b/docs/assets/demo-preview.png differ diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png new file mode 100644 index 0000000..72c9fd8 Binary files /dev/null and b/docs/assets/social-preview.png differ diff --git a/package.json b/package.json index 8495224..a41059c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "wechatpilot", "version": "0.1.0", "private": true, - "description": "Local-first AI automation for WeChat", + "description": "Local-first, outbound-only AI automation for WeChat", "license": "MIT", "homepage": "https://github.com/kdnsna/WeChatPilot#readme", "bugs": { diff --git a/src/cli/demo.ts b/src/cli/demo.ts new file mode 100644 index 0000000..7704f63 --- /dev/null +++ b/src/cli/demo.ts @@ -0,0 +1,87 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface DemoResult { + readonly ok: true; + readonly status: "demo-ready"; + readonly format: "human"; + readonly text: string; + readonly digestPath: string; + readonly imagePath: string; + readonly socialPreviewPath: string; +} + +function packageRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +} + +function ensureDirectory(path: string): void { + mkdirSync(path, { recursive: true, mode: 0o755 }); +} + +export function runOfflineDemo(options: { + readonly projectRoot: string; +}): DemoResult { + const root = options.projectRoot; + if (!isAbsolute(root)) { + throw new Error("Project root must be absolute"); + } + + const sourceRoot = packageRoot(); + const fixtureDigest = join( + sourceRoot, + "tests", + "fixtures", + "demo", + "sample-digest.txt", + ); + const fixtureImage = join(sourceRoot, "docs", "assets", "demo-preview.png"); + const socialPreview = join(sourceRoot, "docs", "assets", "social-preview.png"); + + if (!existsSync(fixtureDigest) || !existsSync(fixtureImage)) { + throw new Error("Demo fixtures are missing from the package"); + } + + const outputDirectory = join(root, "output", "demo"); + ensureDirectory(outputDirectory); + + const digestPath = join(outputDirectory, "sample-digest.txt"); + const imagePath = join(outputDirectory, "demo-preview.png"); + const digest = readFileSync(fixtureDigest, "utf8"); + writeFileSync(digestPath, digest, { encoding: "utf8", mode: 0o644 }); + copyFileSync(fixtureImage, imagePath); + + const text = [ + "WeChatPilot offline demo ready.", + "", + "This demo does not access WeChat, call Codex, or read private .state credentials.", + `Sample digest: ${digestPath}`, + `Sample preview image: ${imagePath}`, + `Social preview asset: ${socialPreview}`, + "", + "Safe next steps:", + "1. npm run wechatpilot -- init", + "2. npm run wechatpilot -- doctor", + "3. ./scripts/init-wechat.sh", + "4. npm run send:test", + "", + "Public Preview only: owner-bound outbound delivery, no remote control.", + ].join("\n"); + + return { + ok: true, + status: "demo-ready", + format: "human", + text: `${text}\n`, + digestPath, + imagePath, + socialPreviewPath: socialPreview, + }; +} diff --git a/src/cli/wechatpilot.ts b/src/cli/wechatpilot.ts index 68f8ab5..9590374 100644 --- a/src/cli/wechatpilot.ts +++ b/src/cli/wechatpilot.ts @@ -31,6 +31,7 @@ import { loadRuntimeManifest, resolveRuntime } from "../config/runtime.js"; import { loadEditorialConfig } from "../observer/editorial-config.js"; import { loadObserverConfig } from "../observer/config.js"; import { buildStabilityReview } from "../observer/stability-review.js"; +import { runOfflineDemo } from "./demo.js"; const LABEL = "io.wechatpilot.daily-tech-digest"; @@ -233,10 +234,13 @@ export async function runWechatPilot(arguments_: readonly string[]): Promise"); + throw new Error("Usage: wechatpilot "); } async function main(): Promise { diff --git a/tests/cli/wechatpilot-demo.test.ts b/tests/cli/wechatpilot-demo.test.ts new file mode 100644 index 0000000..c9e71d6 --- /dev/null +++ b/tests/cli/wechatpilot-demo.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { runWechatPilot } from "../../src/cli/wechatpilot.js"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("wechatpilot demo", () => { + it("generates offline sample artifacts without private state", async () => { + const root = mkdtempSync(join(tmpdir(), "wechatpilot-demo-")); + temporaryRoots.push(root); + + const result = await runWechatPilot(["demo", "--project-root", root]); + const record = result as { + ok: boolean; + status: string; + format: string; + text: string; + digestPath: string; + imagePath: string; + }; + + expect(record.ok).toBe(true); + expect(record.status).toBe("demo-ready"); + expect(record.format).toBe("human"); + expect(record.text).toContain("does not access WeChat"); + expect(record.text).toContain("Safe next steps"); + expect(readFileSync(record.digestPath, "utf8")).toContain("Public Preview Demo"); + expect(readFileSync(record.imagePath).subarray(0, 8)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + }); +}); diff --git a/tests/fixtures/demo/sample-digest.txt b/tests/fixtures/demo/sample-digest.txt new file mode 100644 index 0000000..59c8caa --- /dev/null +++ b/tests/fixtures/demo/sample-digest.txt @@ -0,0 +1,22 @@ +《科技与灵感日报|Public Preview Demo》 + +一、今日技术社区热议 + +【1】本地优先自动化把结果安全送达微信 +发生了什么:WeChatPilot 演示如何把本机 AI 任务结果单向投递给已绑定本人微信。 +为什么热:开发者希望看到自动化结果,但不希望微信反向控制电脑。 +原文:https://github.com/kdnsna/WeChatPilot + +【2】不确定发送必须 fail-closed +发生了什么:当投递结果无法确认时,默认停止自动重放,避免重复发送。 +为什么热:可审计状态与幂等 ledger 是本地自动化的关键安全边界。 +原文:https://github.com/kdnsna/WeChatPilot + +【3】最小权限 Codex 子进程 +发生了什么:候选数据仅通过 stdin 进入只读、非交互、临时 Codex 子进程。 +为什么热:公开预览版本明确禁止微信入站命令、远程 Shell 与 Codex 远程控制。 +原文:https://github.com/kdnsna/WeChatPilot + +二、今日一句判断 + +让本地 AI 自动化安全地抵达微信,而不是让微信反向控制电脑。