From a03ca27b8a34a9a5fcffb76f2e6095be0992a5bf Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:40:56 +0800 Subject: [PATCH 01/14] docs: define release P0 hardening design --- .../2026-07-21-release-p0-hardening-design.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-21-release-p0-hardening-design.md diff --git a/docs/superpowers/specs/2026-07-21-release-p0-hardening-design.md b/docs/superpowers/specs/2026-07-21-release-p0-hardening-design.md new file mode 100644 index 0000000..ae56d53 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-release-p0-hardening-design.md @@ -0,0 +1,95 @@ +# Release P0 Hardening Design + +**Goal:** Close the six release-blocking security, delivery-consistency, runtime, configuration, and CI gaps before public release. + +## Scope and compatibility + +Version 0.1 has a deliberately fixed product contract: simplified-Chinese output, +`Asia/Shanghai`, the existing four digest lanes, and their existing quotas and +length limits. User configuration must reject unsupported alternatives instead of +accepting values that the runtime ignores. + +No WeChat control plane is introduced. The WeChat transport remains a one-way +delivery path only. + +## 1. Redirect-safe WeChat transport + +Every vendor fetch call receives an explicit redirect policy. Requests carrying +credentials, recipient identity, context tokens, messages, or upload parameters +use `redirect: "manual"` and reject any 3xx response before parsing it. QR and +other unauthenticated requests also choose their policy explicitly rather than +inheriting fetch defaults. + +The transport validates initial origins as today and treats manual redirect +responses as terminal transport errors. Future allowed redirects must be an +explicit, separately audited allowlist; this release does not follow any +redirect. Tests cover 301, 302, 307, and 308 and assert that no second request is +made to another origin. + +## 2. Notification disposition and historical recovery + +Delivery state has explicit terminal dispositions in addition to successful +delivery: `not-required` for reports suppressed by the job policy and +`notification-disabled` for `never`. The current run records the matching +disposition when it decides not to notify. + +Historical queuing evaluates the job policy before making an Outbox item and +does not enqueue terminally suppressed reports. Health counts only delivery +states that still need action. Integration tests run two Observer processes for +`always`, `on-failure`, and `never`. + +## 3. Canonical editorial reports + +All report preparation, including issue assignment and final-record replacement, +finishes before the first network send. The result is schema-validated immutable +canonical data. The final JSON record, text/image sends, delivery ledger, +Outbox payload, and payload hash all consume that same canonical report. + +An existing Outbox item is compared by payload hash. It can be replaced only if +it has not started delivery and represents the same run's valid finalization; +otherwise the conflict is retained for manual review. A cross-process recovery +test covers numbered-editorial text success followed by an explicitly rejected +image and the next-process retry. + +## 4. Runtime manifest as an execution input + +`init` writes a private, schema-validated runtime manifest containing resolved +Node and Codex executables, expected versions, and paths. Normal configuration +uses that manifest rather than looking up `codex` on every startup. `doctor` +revalidates existence, mode, identity, and version; it is the controlled repair +path after an upgrade. + +LaunchAgent receives the absolute runtime manifest path and uses the recorded +Node executable. Test fixtures exercise an environment whose PATH contains only +`/usr/bin:/bin`. Configuration schema validation is separated from runtime +discovery so CI needs no installed Codex. + +## 5. Fixed v0.1 schedule and template configuration + +The config schema only accepts `Asia/Shanghai`, `zh-CN`, the documented quotas, +and the fixed word-count contract. CLI schedule installation likewise rejects a +different timezone. README and generated examples describe these as v0.1 fixed +behavior. The fixed system timezone is also documented as a launchd prerequisite, +because launchd calendar triggers are based on the host timezone. + +## 6. Reproducible release gates + +CI uses injected fake runtime data for unit/configuration tests, runs coverage +instead of bare tests, invokes the clean-checkout smoke test, and pins CodeQL +actions by full SHA. The fresh-install smoke test checks out the committed tree +into a new directory rather than copying selected working-tree files. + +Governance scans every tracked file. The obsolete `README 2.md` is removed. +A separate, opt-in compatibility smoke job is the only job allowed to depend on +an actual Codex binary. Issue #5 may close only after this branch is pushed and +the newest remote CI run succeeds. + +## Verification + +- Unit tests for all four redirect statuses and explicit unauthenticated policy. +- Two-process Observer integration coverage for notification disposition and + canonical-report retry behavior. +- Runtime manifest and minimal-PATH LaunchAgent tests. +- Schema tests rejecting unsupported v0.1 configuration. +- CI workflow and fresh-checkout tests, then local typecheck, coverage, build, + governance scan, and fresh-clone smoke. From fd59b75b9dd926d757026d6ade9aab8acf112582 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:43:19 +0800 Subject: [PATCH 02/14] docs: plan release P0 hardening --- .../plans/2026-07-21-release-p0-hardening.md | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-release-p0-hardening.md diff --git a/docs/superpowers/plans/2026-07-21-release-p0-hardening.md b/docs/superpowers/plans/2026-07-21-release-p0-hardening.md new file mode 100644 index 0000000..970af9c --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-release-p0-hardening.md @@ -0,0 +1,297 @@ +# Release P0 Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the six P0 release blockers without expanding the WeChat delivery boundary. + +**Architecture:** Harden the transport at its shared fetch boundary, make report delivery use one persisted canonical payload, and make runtime/configuration explicit inputs. Treat unsupported v0.1 options as validation errors. Make CI create all of its dependencies deterministically. + +**Tech Stack:** Node.js 22, TypeScript, Zod, Vitest, GitHub Actions, macOS launchd. + +--- + +### Task 1: Stop all implicit WeChat redirects + +**Files:** +- Modify: `src/transport/wechat-transport.ts` +- Test: `tests/delivery/wechat.test.ts` +- Test: `tests/security/wechat-boundary.test.ts` + +- [ ] **Step 1: Write failing redirect tests** + +```ts +it.each([301, 302, 307, 308])("rejects authenticated %i redirects without a second request", async (status) => { + const fetchImplementation = vi.fn(async () => new Response(null, { + status, + headers: { location: "https://evil.example/collect" }, + })); + await expect(fetchWithRetry(AUTHENTICATED_URL, { headers: AUTH_HEADERS, fetchImplementation })).rejects.toThrow(/redirect/i); + expect(fetchImplementation).toHaveBeenCalledTimes(1); + expect(fetchImplementation.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); +}); +``` + +- [ ] **Step 2: Run the focused test and confirm red** + +Run: `npx vitest run tests/delivery/wechat.test.ts` + +Expected: redirect test fails because the transport follows the Response/does not set `redirect: "manual"`. + +- [ ] **Step 3: Add the minimal transport policy** + +```ts +const response = await fetch(url, { ...init, redirect: init.redirect ?? "manual", signal }); +if (response.status >= 300 && response.status < 400) { + await response.body?.cancel(); + throw new Error("WeChat transport redirect rejected"); +} +``` + +Ensure QR acquire/poll call sites also pass their explicit `redirect: "manual"` policy and preserve no-follow behavior. + +- [ ] **Step 4: Verify green** + +Run: `npx vitest run tests/delivery/wechat.test.ts tests/security/wechat-boundary.test.ts` + +Expected: all focused tests pass; each 301/302/307/308 case records one request only. + +### Task 2: Persist intentional notification suppression + +**Files:** +- Modify: `src/delivery/run-report-delivery.ts` +- Modify: `src/app/observer.ts` +- Modify: `src/app/health.ts` +- Test: `tests/app/observer.test.ts` +- Test: `tests/app/health.test.ts` + +- [ ] **Step 1: Add failing two-process policy tests** + +```ts +it.each([ + ["always", "success", 1], + ["on-failure", "success", 0], + ["never", "failed", 0], +] as const)("preserves %s notification policy across a second Observer start", async (notify, status, sends) => { + await runObserverJob(fixture({ notify, status })); + await runObserverJob(fixture({ notify, status })); + expect(deliver).toHaveBeenCalledTimes(sends); + expect(inspectRunDeliveryState(root).pending).toBe(0); +}); +``` + +- [ ] **Step 2: Run the test and confirm red** + +Run: `npx vitest run tests/app/observer.test.ts tests/app/health.test.ts` + +Expected: `on-failure` and `never` are queued on the second start or appear as pending. + +- [ ] **Step 3: Implement terminal notification dispositions** + +```ts +export type RunDeliveryDisposition = "missing" | "retryable" | "sent" | "not-required" | "notification-disabled" | "indeterminate"; + +export function recordRunNotificationDisposition(options: { projectRoot: string; report: RunReport; disposition: "not-required" | "notification-disabled" }): void { + writeLedger(options.projectRoot, terminalLedger(options)); +} +``` + +Call this before returning from `!shouldNotify`. In historical queuing, find the job for each report and call `shouldNotify(job, report)` before enqueueing. Exclude the two terminal states from health pending counts. + +- [ ] **Step 4: Verify green** + +Run: `npx vitest run tests/app/observer.test.ts tests/app/observer-outbox.test.ts tests/app/health.test.ts` + +Expected: the two-start matrix passes and intentional suppression is absent from pending health. + +### Task 3: Finalize canonical reports before delivery + +**Files:** +- Modify: `src/app/observer.ts` +- Modify: `src/delivery/outbox.ts` +- Test: `tests/app/observer.test.ts` +- Test: `tests/delivery/outbox.test.ts` + +- [ ] **Step 1: Add the failing numbered-editorial recovery test** + +```ts +it("retries a rejected editorial image with the numbered canonical report after restart", async () => { + await expect(runObserverJob(editorialFixture({ image: "rejected" }))).rejects.toThrow(); + const persisted = listFinalRunReports(root)[0]; + const outbox = readOutbox(root, persisted.runId); + expect(outbox.report).toMatchObject({ issueNumber: 7, issueLabel: "WP-000007" }); + await runObserverJob(editorialFixture({ image: "sent" })); + expect(runDeliveryLedger(root, persisted.runId).reportSha256).toBe(outbox.envelope.payloadSha256); +}); +``` + +- [ ] **Step 2: Run and confirm red** + +Run: `npx vitest run tests/app/observer.test.ts tests/delivery/outbox.test.ts` + +Expected: initial Outbox report lacks the issue fields or has a different payload hash. + +- [ ] **Step 3: Make report preparation canonical** + +```ts +const prepared = await prepareFinalReportArtifacts(options, dependencies, report, "missing"); +const canonical = RunReportSchema.parse(prepared.report); +// all send, ledger, and catch/enqueue paths use canonical +``` + +Prepare once before sender construction and return `{ report, artifact, imageEnabled }` to both the delivery and catch paths. Do not reassign an issue when retrying a canonical report. In `enqueueRunReportOutbox`, compare `stableHash(report)` to the stored payload; replace only an unattempted missing item for the same run, otherwise preserve the existing item for manual review. + +- [ ] **Step 4: Verify green** + +Run: `npx vitest run tests/app/observer.test.ts tests/delivery/outbox.test.ts tests/delivery/run-report-delivery.test.ts` + +Expected: every persisted representation has the identical numbered hash and the restart retry reaches image send. + +### Task 4: Make the runtime manifest authoritative + +**Files:** +- Modify: `src/config/runtime.ts` +- Modify: `src/config/load.ts` +- Modify: `src/observer/config.ts` +- Modify: `src/cli/wechatpilot.ts` +- Test: `tests/config/config.test.ts` +- Test: `tests/cli/wechatpilot.test.ts` +- Test: `tests/launchd/plist.test.ts` + +- [ ] **Step 1: Write failing manifest/minimal-PATH tests** + +```ts +it("loads a validated runtime manifest when PATH has no codex", () => { + writeRuntimeManifest(root, fakeRuntime); + expect(loadConfig(root, { environment: { PATH: "/usr/bin:/bin" } })).toMatchObject({ digest: { codex: { nodeExecutable: fakeRuntime.nodeExecutable } } }); +}); + +it("puts the runtime manifest path in LaunchAgent arguments", () => { + expect(plist).toContain("--runtime-manifest"); +}); +``` + +- [ ] **Step 2: Run and confirm red** + +Run: `npx vitest run tests/config/config.test.ts tests/cli/wechatpilot.test.ts tests/launchd/plist.test.ts` + +Expected: loadConfig invokes PATH discovery and the plist has no manifest argument. + +- [ ] **Step 3: Implement manifest read/validation and dependency injection** + +```ts +export function loadRuntimeManifest(projectRoot: string): ResolvedRuntime { + const path = resolveProjectPath(projectRoot, ".state", "runtime.json"); + assertPrivateRegularFile(path, 0o600); + return ResolvedRuntimeSchema.parse(JSON.parse(readFileSync(path, "utf8"))); +} +``` + +Use discovery only in `init`/explicit doctor repair. Make `loadConfig` accept a runtime or runtime loader dependency; replace observer placeholders from that supplied manifest. Add `--runtime-manifest ` to the LaunchAgent program arguments and use its recorded Node path. Doctor verifies executable and version rather than silently rediscovering. + +- [ ] **Step 4: Verify green** + +Run: `npx vitest run tests/config/config.test.ts tests/cli/wechatpilot.test.ts tests/launchd/plist.test.ts` + +Expected: minimal-PATH fixture passes with its manifest; missing/unsafe manifests fail closed. + +### Task 5: Enforce the fixed v0.1 contract + +**Files:** +- Modify: `src/config/schema.ts` +- Modify: `src/cli/wechatpilot.ts` +- Modify: `README.md` +- Test: `tests/config/config.test.ts` +- Test: `tests/cli/wechatpilot.test.ts` + +- [ ] **Step 1: Write failing rejection tests** + +```ts +it.each([ + ["timezone: Asia/Tokyo", /Asia\/Shanghai/], + ["language: en", /zh-CN/], + ["community: 4", /community/], + ["min: 1800", /2000/], +])("rejects unsupported v0.1 digest configuration: %s", (replacement, message) => { + expect(() => loadConfig(mutatedFixture(replacement))).toThrow(message); +}); +``` + +- [ ] **Step 2: Run and confirm red** + +Run: `npx vitest run tests/config/config.test.ts tests/cli/wechatpilot.test.ts` + +Expected: non-Shanghai, English, and altered values validate today. + +- [ ] **Step 3: Replace ranges with literals and reject CLI drift** + +```ts +timezone: z.literal("Asia/Shanghai"), +language: z.literal("zh-CN"), +quotas: z.object({ community: z.literal(3), news: z.literal(5), github: z.literal(3), deepReading: z.literal(2) }), +length: z.object({ unit: z.literal("chinese-characters"), min: z.literal(2000), max: z.literal(3000) }), +``` + +Reject `schedule install --timezone` unless its value is `Asia/Shanghai`; document host-timezone launchd behavior and fixed template limits. + +- [ ] **Step 4: Verify green** + +Run: `npx vitest run tests/config/config.test.ts tests/cli/wechatpilot.test.ts && npm run wechatpilot -- config validate` + +Expected: default configuration passes; every unsupported value fails clearly. + +### Task 6: Reproduce CI dependencies and release gates + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `package.json` +- Modify: `scripts/fresh-clone-smoke.mjs` +- Create: `scripts/check-tracked-paths.mjs` +- Delete: `README 2.md` +- Test: `tests/project/branding.test.ts` + +- [ ] **Step 1: Add failing workflow/script tests** + +```ts +it("does not retain the obsolete duplicate README", () => { + expect(existsSync(join(projectRoot, "README 2.md"))).toBe(false); +}); +``` + +- [ ] **Step 2: Run and confirm red** + +Run: `npx vitest run tests/project/branding.test.ts && test ! -e 'README 2.md'` + +Expected: the duplicate README exists. + +- [ ] **Step 3: Make CI explicit** + +Use `npm run test:coverage`, `npm run check:fresh-clone`, and a tracked-file scanner driven by `git ls-files -z`, not an allowlist. Have fresh-clone obtain the committed tree via `git archive HEAD` before `npm ci` and build. Replace floating CodeQL tags with verified full SHAs. Keep config tests on injected fake runtime and add a separate real-Codex compatibility job that skips cleanly when unavailable. + +- [ ] **Step 4: Verify green** + +Run: `npm run check:governance && npm run format:check && npm run check:fresh-clone && npx vitest run tests/project/branding.test.ts` + +Expected: clean archive build works, full tracked-file scan is clean, and no obsolete README remains. + +### Task 7: Final release verification + +**Files:** +- Modify: release-relevant files from Tasks 1-6 only + +- [ ] **Step 1: Run focused regression groups** + +Run: `npx vitest run tests/delivery/wechat.test.ts tests/app/observer.test.ts tests/delivery/outbox.test.ts tests/config/config.test.ts tests/cli/wechatpilot.test.ts tests/launchd/plist.test.ts` + +Expected: zero failures. + +- [ ] **Step 2: Run full repository gates** + +Run: `npm run audit:runtime && npm run check:transport-boundary && npm run format:check && npm run test:coverage && npm run typecheck && npm run build && npm run check:fresh-clone && npm run wechatpilot -- config validate` + +Expected: all commands exit zero; coverage thresholds execute. + +- [ ] **Step 3: Inspect change scope** + +Run: `git status --short && git diff --check && git diff --stat main...HEAD` + +Expected: only the scoped P0 implementation, tests, docs, and CI files changed. From 7d0a8c8d28d504cdb019b1be604e969a5b0e7fff Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:44:38 +0800 Subject: [PATCH 03/14] fix: reject WeChat transport redirects --- src/transport/wechat-transport.ts | 24 ++++++++++- tests/transport/wechat-transport.test.ts | 52 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/transport/wechat-transport.test.ts diff --git a/src/transport/wechat-transport.ts b/src/transport/wechat-transport.ts index b1ce06f..2c6b5a4 100644 --- a/src/transport/wechat-transport.ts +++ b/src/transport/wechat-transport.ts @@ -34,6 +34,14 @@ export interface WechatTransportCredentials { readonly ilinkUserId: string; } +export class WechatTransportRedirectError extends Error { + override readonly name = "WechatTransportRedirectError"; + + constructor() { + super("WeChat transport redirect rejected"); + } +} + function errorCode(error: unknown): string | undefined { let current = error as { readonly code?: unknown; readonly cause?: unknown } | undefined; for (let depth = 0; current !== undefined && depth < 6; depth += 1) { @@ -66,6 +74,10 @@ function delayFor(attempt: number, base: number, maximum: number): number { return Math.floor(Math.min(maximum, base * 2 ** attempt) * (0.5 + Math.random() * 0.5)); } +function isRedirectStatus(status: number): boolean { + return status >= 300 && status < 400; +} + export async function fetchWithRetry( url: string, options: WechatTransportFetchOptions = {}, @@ -91,7 +103,11 @@ export async function fetchWithRetry( ? AbortSignal.timeout(timeoutMs) : AbortSignal.any([externalSignal, AbortSignal.timeout(timeoutMs)]); try { - const response = await fetch(url, { ...init, signal }); + const response = await fetch(url, { ...init, redirect: "manual", signal }); + if (isRedirectStatus(response.status)) { + await response.body?.cancel(); + throw new WechatTransportRedirectError(); + } if ( retryOnHttpError && (response.status === 429 || response.status >= 500) && @@ -108,6 +124,9 @@ export async function fetchWithRetry( return response; } catch (error) { lastError = error; + if (error instanceof WechatTransportRedirectError) { + throw error; + } if (externalSignal?.aborted) { throw externalSignal.reason instanceof Error ? externalSignal.reason @@ -145,7 +164,7 @@ export async function login( for (let refresh = 0; refresh < 3; refresh += 1) { const qrResponse = await fetchWithRetry( `${DEFAULT_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`, - { retries: 4, retryOnHttpError: true, timeoutMs: 20_000 }, + { retries: 4, retryOnHttpError: true, timeoutMs: 20_000, redirect: "manual" }, ); if (!qrResponse.ok) { throw new Error("WeChat QR request failed"); @@ -168,6 +187,7 @@ export async function login( headers: { "iLink-App-ClientVersion": "1" }, retries: 1, timeoutMs: 45_000, + redirect: "manual", }, ); if (!statusResponse.ok) { diff --git a/tests/transport/wechat-transport.test.ts b/tests/transport/wechat-transport.test.ts new file mode 100644 index 0000000..c51efe8 --- /dev/null +++ b/tests/transport/wechat-transport.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithRetry } from "../../src/transport/wechat-transport.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("WeChat transport redirects", () => { + it.each([301, 302, 307, 308])( + "rejects %i without following an authenticated request to another origin", + async (status) => { + const fetchMock = vi.fn(async () => new Response(null, { + status, + headers: { location: "https://untrusted.example/collect" }, + })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchWithRetry("https://ilinkai.weixin.qq.com/ilink/bot/sendmessage", { + headers: { + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ context_token: "test-context", to_user_id: "test-user" }), + method: "POST", + retries: 0, + }), + ).rejects.toThrow(/redirect/i); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "https://ilinkai.weixin.qq.com/ilink/bot/sendmessage", + expect.objectContaining({ redirect: "manual" }), + ); + }, + ); + + it("uses an explicit no-follow policy for an unauthenticated QR request", async () => { + const fetchMock = vi.fn(async () => new Response("{}")); + vi.stubGlobal("fetch", fetchMock); + + await fetchWithRetry("https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode", { + retries: 0, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode", + expect.objectContaining({ redirect: "manual" }), + ); + }); +}); From 35e30aaa259a38ab4cea6cee535123de578c6227 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:48:42 +0800 Subject: [PATCH 04/14] fix: persist suppressed report notifications --- src/app/observer.ts | 33 ++++++++++- src/delivery/run-report-delivery.ts | 53 +++++++++++++++++ tests/app/observer.test.ts | 67 ++++++++++++++++++++++ tests/delivery/run-report-delivery.test.ts | 28 +++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/src/app/observer.ts b/src/app/observer.ts index 909634b..d0e27c4 100644 --- a/src/app/observer.ts +++ b/src/app/observer.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { deliverRunReport, + recordRunReportNotificationDisposition, runDeliveryDisposition, type DeliverRunReportOptions, type DeliverRunReportResult, @@ -100,6 +101,7 @@ export interface ObserverDependencies { readonly projectRoot: string; readonly report: RunReport; }) => RunDeliveryDisposition; + readonly recordNotificationDisposition?: typeof recordRunReportNotificationDisposition; readonly acquireTaskLock?: ( options: AcquireObserverTaskLockOptions, ) => Promise; @@ -153,6 +155,7 @@ interface ResolvedObserverDependencies { readonly projectRoot: string; readonly report: RunReport; }) => RunDeliveryDisposition; + readonly recordNotificationDisposition: typeof recordRunReportNotificationDisposition; readonly acquireTaskLock: ( options: AcquireObserverTaskLockOptions, ) => Promise; @@ -195,6 +198,8 @@ function resolvedDependencies( listFinalReports: supplied?.listFinalReports ?? listFinalRunReports, deliveryDisposition: supplied?.deliveryDisposition ?? runDeliveryDisposition, + recordNotificationDisposition: + supplied?.recordNotificationDisposition ?? recordRunReportNotificationDisposition, acquireTaskLock: supplied?.acquireTaskLock ?? acquireObserverTaskLock, runJob: supplied?.runJob ?? runManagedJob, normalize: supplied?.normalize ?? buildRunReport, @@ -439,9 +444,19 @@ async function queueHistoricalReports( options: RunObserverJobOptions, dependencies: ResolvedObserverDependencies, recovery: RecoverInterruptedRunsResult, + config: ObserverConfig, ): Promise { for (const authority of recovery.recovered) { const report = recoveredReport(options, dependencies, authority); + let reportJob: ObserverJob; + try { + reportJob = findObserverJob(config, report.taskId); + } catch { + continue; + } + if (!shouldNotify(reportJob, report)) { + continue; + } const disposition = dependencies.deliveryDisposition({ projectRoot: options.projectRoot, report, @@ -457,6 +472,15 @@ async function queueHistoricalReports( } for (const report of dependencies.listFinalReports(options.projectRoot)) { + let reportJob: ObserverJob; + try { + reportJob = findObserverJob(config, report.taskId); + } catch { + continue; + } + if (!shouldNotify(reportJob, report)) { + continue; + } const disposition = dependencies.deliveryDisposition({ projectRoot: options.projectRoot, report, @@ -525,7 +549,7 @@ export async function runObserverJob( ...(options.now === undefined ? {} : { now: options.now }), }); - await queueHistoricalReports(options, dependencies, recovery); + await queueHistoricalReports(options, dependencies, recovery, config); await drainHistoricalOutbox(options, dependencies); if (recovery.activeTaskIds.includes(job.id)) { @@ -555,6 +579,13 @@ export async function runObserverJob( dependencies.finishRecord(options.projectRoot, report); if (!shouldNotify(job, report)) { + dependencies.recordNotificationDisposition({ + projectRoot: options.projectRoot, + report, + disposition: + job.notify === "never" ? "notification-disabled" : "not-required", + ...(options.now === undefined ? {} : { now: options.now }), + }); return report; } diff --git a/src/delivery/run-report-delivery.ts b/src/delivery/run-report-delivery.ts index 1b19894..6fd70aa 100644 --- a/src/delivery/run-report-delivery.ts +++ b/src/delivery/run-report-delivery.ts @@ -135,6 +135,13 @@ const SentLedgerSchema = LedgerBaseSchema.extend({ } }); +const NotificationSuppressedLedgerSchema = LedgerBaseSchema.extend({ + status: z.enum(["not-required", "notification-disabled"]), +}).strict().refine( + (ledger) => ledger.textAttempt === 0 && ledger.imageAttempt === 0, + "suppressed notifications must not have delivery attempts", +); + const DeliveryLedgerSchema = z.union([ TextSendingLedgerSchema, TextSentLedgerSchema, @@ -142,6 +149,7 @@ const DeliveryLedgerSchema = z.union([ FailedLedgerSchema, IndeterminateLedgerSchema, SentLedgerSchema, + NotificationSuppressedLedgerSchema, ]); type DeliveryLedger = z.infer; @@ -204,6 +212,8 @@ export type RunDeliveryDisposition = | "missing" | "retryable" | "sent" + | "not-required" + | "notification-disabled" | "indeterminate" | "blocked"; @@ -427,6 +437,40 @@ export function runReportSha256(report: RunReport): string { .digest("hex"); } +export function recordRunReportNotificationDisposition(options: { + readonly projectRoot: string; + readonly report: RunReport; + readonly disposition: "not-required" | "notification-disabled"; + readonly now?: () => Date; +}): void { + const report = RunReportSchema.parse(options.report); + const now = options.now ?? (() => new Date()); + ensureStateDirectories(options.projectRoot); + const hash = runReportSha256(report); + const current = readLedger(options.projectRoot, report.runId, hash); + if (current.kind === "invalid") { + throw new RunReportDeliveryIndeterminateError(); + } + if (current.ledger !== undefined) { + const existing = disposition(current.ledger); + if (existing === options.disposition || existing === "sent") { + return; + } + throw new RunReportDeliveryIndeterminateError(); + } + const recordedAt = timestamp(now); + writeAfterAttempt(options.projectRoot, { + version: 1, + status: options.disposition, + runId: report.runId, + reportSha256: hash, + startedAt: recordedAt, + updatedAt: recordedAt, + textAttempt: 0, + imageAttempt: 0, + }); +} + function timestamp(now: () => Date): string { const value = now(); if (!Number.isFinite(value.getTime())) { @@ -927,6 +971,12 @@ function disposition(ledger: DeliveryLedger): RunDeliveryDisposition { if (ledger.status === "sent") { return "sent"; } + if ( + ledger.status === "not-required" || + ledger.status === "notification-disabled" + ) { + return ledger.status; + } if ( ledger.status === "indeterminate" || ledger.status === "text-sending" || @@ -937,6 +987,9 @@ function disposition(ledger: DeliveryLedger): RunDeliveryDisposition { if (ledger.status === "text-sent") { return "retryable"; } + if (ledger.status !== "failed") { + return "indeterminate"; + } if (ledger.failureKind !== "rejected") { return "blocked"; } diff --git a/tests/app/observer.test.ts b/tests/app/observer.test.ts index bb5e3e9..f42a17e 100644 --- a/tests/app/observer.test.ts +++ b/tests/app/observer.test.ts @@ -579,6 +579,73 @@ describe("observer orchestration", () => { expect(calls).toEqual([reports[0]!.runId, reports[1]!.runId, "run-job", CURRENT_ID]); }); + it.each([ + ["on-failure", ["failed"], 1], + ["never", [], 0], + ] as const)( + "does not enqueue intentionally suppressed historical reports for %s", + async (notify, expectedStatuses, expectedEnqueues) => { + const successful = report("018f514c-1a2b-7abc-8def-1234567890d1", "success"); + const failed = report("018f514c-1a2b-7abc-8def-1234567890d2", "failed"); + const enqueueOutbox = vi.fn(dependencies().enqueueOutbox); + const policyJob = { ...job, notify }; + const deps = dependencies({ + loadConfig: vi.fn(() => ({ version: 1 as const, jobs: [policyJob] } as ObserverConfig)), + listFinalReports: vi.fn(() => [successful, failed]), + deliveryDisposition: vi.fn(() => "missing" as const), + enqueueOutbox, + recordNotificationDisposition: vi.fn(), + runJob: vi.fn(async () => ({ + authority: authority(CURRENT_ID, "success"), + draft: null, + })), + }); + + await runObserverJob({ + projectRoot: ROOT, + jobId: policyJob.id, + dependencies: deps, + }); + + expect(enqueueOutbox).toHaveBeenCalledTimes(expectedEnqueues); + expect(enqueueOutbox.mock.calls.map(([options]) => options.report.status)).toEqual( + expectedStatuses, + ); + }, + ); + + it.each([ + ["on-failure", "success", "not-required"], + ["never", "failed", "notification-disabled"], + ] as const)( + "records %s when the current %s report is intentionally suppressed", + async (notify, status, disposition) => { + const recordNotificationDisposition = vi.fn(); + const policyJob = { ...job, notify }; + const deps = dependencies({ + loadConfig: vi.fn(() => ({ version: 1 as const, jobs: [policyJob] } as ObserverConfig)), + runJob: vi.fn(async () => ({ + authority: authority(CURRENT_ID, status), + draft: null, + })), + }) as ObserverDependencies & { recordNotificationDisposition: typeof recordNotificationDisposition }; + deps.recordNotificationDisposition = recordNotificationDisposition; + + await runObserverJob({ + projectRoot: ROOT, + jobId: policyJob.id, + dependencies: deps, + }); + + expect(recordNotificationDisposition).toHaveBeenCalledWith({ + projectRoot: ROOT, + report: report(CURRENT_ID, status), + disposition, + }); + expect(deps.deliver).not.toHaveBeenCalled(); + }, + ); + it("refuses a new run when the old runner is dead but its child is still alive", async () => { const root = projectRoot(); startRunRecord({ diff --git a/tests/delivery/run-report-delivery.test.ts b/tests/delivery/run-report-delivery.test.ts index 68d2b9b..b16eca9 100644 --- a/tests/delivery/run-report-delivery.test.ts +++ b/tests/delivery/run-report-delivery.test.ts @@ -34,6 +34,7 @@ import { runDeliveryDisposition, runReportSha256, } from "../../src/delivery/run-report-delivery.js"; +import * as runReportDelivery from "../../src/delivery/run-report-delivery.js"; import { WechatDeliveryIndeterminateError, WechatDeliveryRejectedError, @@ -829,6 +830,33 @@ describe("run report delivery ledger", () => { expect(existsSync(join(root, ".state"))).toBe(false); }); + it.each([ + ["not-required", "not-required"], + ["notification-disabled", "notification-disabled"], + ] as const)("persists %s as a terminal notification disposition", (requested, expected) => { + const root = project(); + const record = ( + runReportDelivery as unknown as { + recordRunReportNotificationDisposition?: (options: { + readonly projectRoot: string; + readonly report: RunReport; + readonly disposition: "not-required" | "notification-disabled"; + }) => void; + } + ).recordRunReportNotificationDisposition; + + expect(record).toBeTypeOf("function"); + if (record === undefined) return; + record({ projectRoot: root, report: report(), disposition: requested }); + + expect(runDeliveryDisposition({ projectRoot: root, report: report() })).toBe(expected); + expect(inspectRunDeliveryState({ projectRoot: root, reports: [report()] })).toEqual({ + pending: 0, + indeterminate: 0, + invalid: 0, + }); + }); + it("locally sanitizes untrusted authority fields into bounded single lines", () => { const secret = `sk-${"a".repeat(24)}`; const text = formatRunReportText( From ed560c47cc216b7dd5cbde5e876dd188fed5b696 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:51:27 +0800 Subject: [PATCH 05/14] fix: keep canonical reports in delivery outbox --- src/app/observer.ts | 58 +++++++++++++++++++++++------------ src/delivery/outbox.ts | 20 ++++++++++++ tests/app/observer.test.ts | 20 ++++++++++++ tests/delivery/outbox.test.ts | 16 ++++++++++ 4 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/app/observer.ts b/src/app/observer.ts index d0e27c4..1523581 100644 --- a/src/app/observer.ts +++ b/src/app/observer.ts @@ -58,6 +58,7 @@ import { import { attachIssue, buildRunReport, + RunReportSchema, type BuildRunReportOptions, type RunAuthority, type RunReport, @@ -386,29 +387,46 @@ async function deliverFinalReport( throw new Error(SAFE_RENDER_ERROR); } - const senderOptions = { - projectRoot: options.projectRoot, - report: prepared.report, - ...(options.signal === undefined ? {} : { signal: options.signal }), - }; - const sendText = await dependencies.prepareText(senderOptions); - const sendImage = prepared.imageEnabled - ? await dependencies.prepareImage(senderOptions) - : async () => ({ status: "sent" as const }); + try { + const senderOptions = { + projectRoot: options.projectRoot, + report: prepared.report, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }; + const sendText = await dependencies.prepareText(senderOptions); + const sendImage = prepared.imageEnabled + ? await dependencies.prepareImage(senderOptions) + : async () => ({ status: "sent" as const }); - await dependencies.deliver({ - projectRoot: options.projectRoot, - report: prepared.report, - sendText, - sendImage, - ...(prepared.artifact === undefined - ? {} - : { imagePath: prepared.artifact.pngPath }), - ...(options.now === undefined ? {} : { now: options.now }), - }); + await dependencies.deliver({ + projectRoot: options.projectRoot, + report: prepared.report, + sendText, + sendImage, + ...(prepared.artifact === undefined + ? {} + : { imagePath: prepared.artifact.pngPath }), + ...(options.now === undefined ? {} : { now: options.now }), + }); + } catch (error) { + if (typeof error === "object" && error !== null) { + Object.defineProperty(error, "canonicalReport", { + value: prepared.report, + enumerable: false, + }); + } + throw error; + } return prepared.report; } +function canonicalReportFromError(error: unknown, fallback: RunReport): RunReport { + if (typeof error !== "object" || error === null) return fallback; + const candidate = (error as { readonly canonicalReport?: unknown }).canonicalReport; + const parsed = RunReportSchema.safeParse(candidate); + return parsed.success ? parsed.data : fallback; +} + function recoveredReport( options: RunObserverJobOptions, dependencies: ResolvedObserverDependencies, @@ -599,7 +617,7 @@ export async function runObserverJob( } catch (error) { dependencies.enqueueOutbox({ projectRoot: options.projectRoot, - report, + report: canonicalReportFromError(error, report), priority: 5, ...(options.now === undefined ? {} : { now: options.now }), }); diff --git a/src/delivery/outbox.ts b/src/delivery/outbox.ts index 3a2bcb1..55b45b7 100644 --- a/src/delivery/outbox.ts +++ b/src/delivery/outbox.ts @@ -101,6 +101,26 @@ export function enqueueRunReportOutbox(options: { const id = report.runId; const existing = readItem(options.projectRoot, id); if (existing !== null) { + const payloadSha256 = stableHash(report); + if (existing.envelope.payloadSha256 === payloadSha256) { + return existing; + } + if ( + existing.envelope.disposition === "missing" && + existing.envelope.attempt === 0 + ) { + const updated: OutboxItem = { + ...existing, + envelope: { + ...existing.envelope, + payloadSha256, + }, + report, + ...(options.imagePath === undefined ? {} : { imagePath: options.imagePath }), + }; + writeItem(options.projectRoot, updated); + return updated; + } return existing; } const item: OutboxItem = { diff --git a/tests/app/observer.test.ts b/tests/app/observer.test.ts index f42a17e..f5992d2 100644 --- a/tests/app/observer.test.ts +++ b/tests/app/observer.test.ts @@ -854,4 +854,24 @@ describe("observer orchestration", () => { expect(listFinalRunReports(root)).toEqual([report(CURRENT_ID)]); expect(release).toHaveBeenCalledOnce(); }); + + it("enqueues the numbered canonical editorial report when image delivery is rejected", async () => { + const enqueued: RunReport[] = []; + const deps = dependencies({ + normalize: vi.fn(() => editorialReport(CURRENT_ID)), + deliver: vi.fn(async () => { + throw new WechatDeliveryRejectedError(); + }), + enqueueOutbox: vi.fn((options) => { + enqueued.push(options.report); + return dependencies().enqueueOutbox!(options); + }), + }); + + await expect( + runObserverJob({ projectRoot: ROOT, jobId: job.id, dependencies: deps }), + ).rejects.toBeInstanceOf(WechatDeliveryRejectedError); + + expect(enqueued).toEqual([numberedEditorialReport(CURRENT_ID)]); + }); }); diff --git a/tests/delivery/outbox.test.ts b/tests/delivery/outbox.test.ts index 51b4def..62729e0 100644 --- a/tests/delivery/outbox.test.ts +++ b/tests/delivery/outbox.test.ts @@ -106,4 +106,20 @@ describe("delivery outbox", () => { ) as { envelope: { disposition: string } }; expect(stored.envelope.disposition).toBe("manual-review"); }); + + it("replaces an unattempted payload with the canonical finalized report", () => { + const root = project(); + const initial = report("018f514c-1a2b-7abc-8def-1234567890c2"); + const canonical: RunReport = { + ...initial, + issueNumber: 7, + issueLabel: "WP-000007", + }; + const initialItem = enqueueRunReportOutbox({ projectRoot: root, report: initial }); + + const item = enqueueRunReportOutbox({ projectRoot: root, report: canonical }); + + expect(item.report).toEqual(canonical); + expect(item.envelope.payloadSha256).not.toBe(initialItem.envelope.payloadSha256); + }); }); From 558bb32fc6dbe666a249c5cd8fc1ee852706a2ea Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:01:19 +0800 Subject: [PATCH 06/14] fix: harden runtime config and release gates --- .github/workflows/ci.yml | 11 +- README 2.md | 264 ---------------------------------- README.md | 6 +- package-lock.json | 244 ++++++++++++++++++++++++++++++- package.json | 1 + scripts/fresh-clone-smoke.mjs | 28 +--- src/cli/wechatpilot.ts | 14 +- src/config/load.ts | 15 +- src/config/runtime.ts | 11 +- src/config/schema.ts | 21 ++- src/observer/config.ts | 6 +- 11 files changed, 302 insertions(+), 319 deletions(-) delete mode 100644 README 2.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7ec03f..71116af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Governance and portable-path checks run: | npm run check:governance - if rg -n '/Users/|/opt/homebrew/|com\.kdnsna' README.md package.json scripts launchd src config .github AGENTS.md CONTRIBUTING.md SUPPORT.md ARCHITECTURE.md PRIVACY.md DATA_FLOW.md COMPATIBILITY.md; then + if git ls-files -z | xargs -0 rg -n '/Users/|/opt/homebrew/|com\.kdnsna'; then echo 'personal absolute path detected in version-controlled files' >&2 exit 1 fi @@ -47,9 +47,10 @@ jobs: npm run audit:runtime npm run check:transport-boundary npm run format:check - npm test + npm run test:coverage npm run typecheck npm run build + npm run check:fresh-clone npm run wechatpilot -- config validate - name: Reject upstream remote-control imports @@ -72,10 +73,10 @@ jobs: with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@ddf5ce7296213f5548c91e2dd19df2d77d2b2d66 # v3 with: languages: javascript-typescript - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@ddf5ce7296213f5548c91e2dd19df2d77d2b2d66 # v3 - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@ddf5ce7296213f5548c91e2dd19df2d77d2b2d66 # v3 diff --git a/README 2.md b/README 2.md deleted file mode 100644 index 4b9326f..0000000 --- a/README 2.md +++ /dev/null @@ -1,264 +0,0 @@ -# WeChatPilot - -> Local-first AI automation for WeChat - -WeChatPilot 是一个运行在本机的微信 AI 自动化平台。当前内置的第一个工作流是“每日科技情报”:它每天收集过去 24 小时的技术信号,用透明指标完成归一化、聚类、排序和七日去重,再通过受限的 `codex exec` 生成中文日报,最后单向推送到扫码绑定的微信 Chatbot。后续工作流将复用同一套本地调度、权限、投递和审计基础设施。 - -> [!IMPORTANT] -> WeChatPilot 是独立、非微信或腾讯官方项目,与微信及腾讯不存在隶属、授权或背书关系。 - -项目不会从微信执行命令,不会启动 `cli-in-wechat` 的 Router、Codex 适配器或远程控制入口,也不会使用 `--dangerously-bypass-approvals-and-sandbox`。微信不能启动、批准、恢复、停止或修改 Codex 任务;它在本项目中始终只是单向投递终点。微信凭据和 context token 只保存在本项目被 Git 忽略的 `.state/wechat/`,目录权限为 `0700`、文件权限为 `0600`;命令输出和日志只报告就绪状态,不显示具体值、用户 ID 或令牌。 - -## 产品方向 - -WeChatPilot 将科技日报作为首个经过真实运行验证的内置工作流,而不是把产品限制为资讯工具。后续版本会逐步抽象消息与事件接口、工作流注册、最小权限策略和可审计的本地插件机制。任何入站消息能力都必须经过独立安全设计;默认边界继续是单向发送,不开放远程 Shell、任意 Mac 命令或 Codex 远程控制。 - -## 运行环境 - -- macOS 当前用户权限,不需要 `sudo`。 -- 固定运行时:`/opt/homebrew/opt/node@22/bin/node`(已验证为 Node 22.23.1)。 -- Codex:由 `config/digest.yaml` 固定为检测到的 Node 可执行文件与官方 CLI JavaScript 入口。 -- 微信依赖:`vendor/cli-in-wechat` Git submodule,固定提交 `75d7db9338e133080fef9914b92ceb5e3ebd0dfc`,不跟随任何分支。构建后仅对未跟踪的 QR 运行时产物应用已审计的 45 秒长轮询补丁,上游源码和 submodule 指针保持不变。 - -不要运行 vendor 内的 `npm start`、`npm run dev` 或其主入口。项目只校验并动态加载审计过的 QR、HTTP 和加密辅助模块;构建产物必须同时通过 `config/vendor-wechat-artifacts.json` 的大小和 SHA-256 清单。 - -## 初始化与本地命令 - -克隆仓库并初始化 vendor、构建产物和全部测试: - -```sh -git clone --recurse-submodules https://github.com/kdnsna/WeChatPilot.git -cd WeChatPilot -./scripts/init-project.sh -``` - -当前版本以 macOS、Homebrew Node.js 22 和作者已验证的 LaunchAgent 配置为运行基线。仓库中的部署配置保留了现有服务的绝对路径与身份;在另一台 Mac 上启用定时发送前,请先审阅并适配 `config/`、`launchd/` 和相关脚本,不要直接加载示例 LaunchAgent。 - -健康检查(不输出路径、用户 ID 或令牌): - -```sh -./scripts/health-check.sh -``` - -输出中的 `observer` 只有配置/渲染器/Image 2 就绪布尔值,以及待恢复、待发送、不确定投递、待生成视觉、固定背景视觉和无效状态记录的安全计数,不包含 run ID、期号、提示词或路径。待处理计数会把整体状态标为 `pending`,不代表必需环境损坏;无效配置、编号/视觉账本损坏、渲染器不可用或 Image 2 能力标记不匹配则返回 `failed`。健康检查不会为自证就绪而现场生成图片。若微信登录过期,重新运行 `./scripts/init-wechat.sh` 扫码初始化,不要手工编辑私有状态文件。 - -只准备 Codex 输入、检查安全参数而不调用 Codex: - -```sh -./scripts/generate-digest.sh --dry-run -``` - -真实采集并生成本地日报,但不发送微信: - -```sh -./scripts/generate-digest.sh -``` - -GitHub Token 是可选项;不设置时使用公开 API 降级运行。若临时提供,只应通过当前进程环境传入,不要写入仓库、配置或日志。 - -## 微信初始化与发送 - -首次绑定: - -```sh -./scripts/init-wechat.sh -``` - -脚本显示二维码后: - -1. 用本人微信扫码并在手机确认。 -2. 向刚绑定的 Chatbot 发送一条纯文本 `初始化`(不要发送 `/init`)。 -3. 脚本只接受扫码返回的本人账号、单一文本项和本次扫码开始之后的新消息;其他账号、旧消息、媒体和引用消息都被忽略。 - -如果当前客户端隐藏了终端二维码,初始化会在 `.state/wechat/login-qr.svg` 写入一个权限受限、被 Git 忽略的临时副本,供本机界面显示;绑定完成后可以删除该临时图,不影响登录状态。初始化有明确超时,登录失效时退出并要求重新扫码,不会后台无限重试。`allowedUsers` 在本地绑定状态中必须恰好包含扫码本人,发送命令不提供 `--user` 或其他目标选择能力。 - -发送测试消息: - -```sh -./scripts/send-test.sh -``` - -显式发送一张固定值守员测试卡(会真实向已绑定本人微信发送文字和 PNG): - -```sh -./scripts/send-run-report-test.sh -``` - -测试卡没有收件人、命令、附件、模型、提示词或消息覆盖参数。Codex 内置 Image 2 只生成不含文字的主视觉;所有中文、期号、信号、行动和审计字段由本地确定性模板渲染。 - -发送指定日期的完整日报: - -```sh -./scripts/send-digest.sh --date 2026-07-18 -``` - -长文本遵循固定上游逻辑:每段最多 2000 个 JavaScript 字符单元,依次优先在段落、换行、空格处分段,最后才硬切,段间等待 300ms。主动发送 POST 不做未经证明的网络重放;响应丢失或部分分段成功会记为 `indeterminate`,当天停止自动重发,避免重复。完整日报使用原子锁和每日 ledger;成功后同一天任何再次调用都会被拒绝。确定失败最多允许初次加两次重试。 - -## 编辑部情报长图与 Image 2 - -每个新的最终 Observer 运行报告会获得一个连续期号,例如 `WP-000021`,并对应唯一的视觉账本、主视觉和最终 PNG。成功、跳过、失败、超时、取消、中断和恢复报告共用同一条期号序列。同一 run 重试时复用原期号、主视觉、报告内容和最终长图,不再分配新期号或重复生成。 - -长图固定为 1080 × 2400 PNG,从上到下包含五个区域: - -1. 无文字主视觉、报告身份、期号、状态与时间。 -2. `EXECUTIVE BRIEF` 今日结论。 -3. 最多五项带 `01`–`05` 编号的重点信号、为何重要、来源数、置信度和影响。 -4. 三项可执行的下一步行动。 -5. Codex 执行与交付审计,包含候选/入选数、来源覆盖、耗时、视觉模式和短 run ID。 - -Image 2 使用本机已登录的 Codex 内置认证,项目不读取、保存或要求 OpenAI API key。图片子进程仅接收经脱敏的视觉摘要,从 stdin 输入,且固定为临时、非交互、项目根目录、`read-only`、`approval never`。它只启用 `image_generation`,显式关闭 shell、unified exec、browser、computer use、Apps、plugins、hooks、多代理和额外目录;不接收微信消息、收件人、凭据、源 URL、完整日报、本机私有路径或可执行指令。 - -先验证能力,再生成本地预览,最后才实发: - -```sh -npm run report:image-probe -npm run health -npm run report:preview -npm run report:test -``` - -`report:image-probe` 会调用一次相同的生产图片路径,完成后只保存 Codex 版本、样式版本、成功时间和产物哈希,并删除经文件身份核对的项目内探测图。`report:preview` 生成固定长图但不加载任何微信发送器。`report:test` 生成五项固定信号与三项行动,获得真实期号,然后向已绑定本人发送一次文字和长图。 - -主视觉最多尝试三次;三次都失败后生成确定性品牌背景,图上如实标注 `AI 主视觉暂不可用`,详细正文仍可投递。如果最终合成失败,文字和图片都不发送,最终报告与视觉账本保留供恢复;只有微信明确拒绝才在次数上限内重试,响应丢失则记为 `indeterminate` 且永不自动重放。 - -图片在微信发送之前已完成渲染,因此它只能声明 `报告图:已生成`,不得声明自己未来的微信投递已经成功。真实投递结果只由图片之外的私有 delivery ledger 确认。 - -紧急回滚无需更换代码或破坏新账本:只将 `config/report-visual.yaml` 中的 `mode: editorial` 改为 `mode: legacy`,再重新加载同一份 schema-compatible Observer 构建。legacy 模式仍会分配期号,但跳过 Image 2 并使用旧报告卡渲染器。保留所有最终报告、期号、视觉账本和不确定投递记录,不要重置状态或自动补发。 - -## 数据源与降级 - -首期配置位于 `config/sources.yaml`: - -- Hacker News:top/new stories,保留分数、评论、作者、时间和 HN 讨论页。 -- V2EX:热门主题,保留回复数、节点、作者和链接。 -- GitHub:公开 Search API;可选 Token。保留 Star、Fork、语言、更新时间和本地快照测得的真实增长窗口。首次运行没有历史快照时明确标为不可得。 -- 科技媒体与 AI 官方博客 RSS:每个 feed 独立失败,列表可在 YAML 中调整。 -- Reddit RSS:可降级来源;403、429、超时或解析失败不会拖垮其他来源。 - -采集器通过 `Promise.allSettled` 风格的结果封装彼此隔离。单源失败会写入脱敏状态并继续;只有可用核心来源不足时任务才失败。HTTP 请求不携带环境 Cookie,设有超时、大小和并发上限。 - -## 热度公式 - -不同平台的原始评论、回复、积分和 Star 不直接相加。每项指标先按来源家族做 midrank 百分位;真实的 0 保留为观测值,缺失指标使用中性值 `0.5`。 - -默认分数(所有权重均可在 `config/ranking.yaml` 调整): - -```text -base = - 0.24 × engagement_percentile - + 0.20 × popularity_percentile - + 0.18 × velocity_percentile - + 0.18 × 2^(-age_hours / 12) - + 0.12 × cross_source_confirmation - + 0.08 × source_quality - -heatScore = 100 × base × historyFactor -``` - -- `engagement`:HN/Reddit 评论数、V2EX 回复数等来源内讨论指标。 -- `popularity`:来源内积分或 Star。 -- `velocity`:社区评论每小时;GitHub 优先使用本地快照按真实窗口归一化的 Star 增长,快照不可得时才显示项目生命周期速率,绝不冒充 24 小时新增。 -- `recency`:12 小时半衰期。 -- `cross_source_confirmation`:同一事件的不同来源数,默认 4 个来源封顶。 -- `source_quality`:HN/GitHub 1.0、V2EX/RSS 0.9、Reddit 0.7。 -- `historyFactor`:过去 7 天已推送的同链接或高相似标题默认乘 `0.35`,否则为 `1.0`。 - -生成日报时会写入一份与正文 SHA-256 绑定的私有入选清单;只有微信明确确认发送成功后,才把其中的真实入选 URL、标题和来源写入 `data/history/sent-items.json`。这样下一次排名读取的确实是“已推送历史”,生成失败或微信明确拒绝不会误降权。 - -聚类先移除常见跟踪参数并比较规范 URL,再使用标题 token 与字符 bigram 相似度。一个事件只保留一个代表条目,但相关来源、交叉印证和可访问备选链接会保留给日报生成器。排名结果中的 `components`、`reason`、`confirmedBy` 和 `velocityMetric` 可解释每条内容为何入选。 - -## Codex 安全边界 - -应用先根据本机实际 `codex exec --help` 固化兼容参数。当前调用使用绝对 Node 22 和 Codex JavaScript 入口,并包含: - -```text ---search ---sandbox read-only ---ask-for-approval never ---cd -exec --ephemeral --ignore-user-config --ignore-rules ---color never --output-schema - -``` - -候选数据只经 stdin 传入;子进程环境采用白名单,移除 GitHub/OpenAI/bot 等 Token。apps、plugins、hooks、shell、browser/computer-use、多代理、工作区依赖和额外目录能力被显式关闭。输出必须通过本地 JSON Schema、标题/日期、3/5/3/2 栏目、2000–3000 正文字符和来源 URL 双向一致校验;失败最多重写两次。 - -生成后还会对 13 个来源做无凭据 GET 检查:15 秒总期限、逐跳 DNS 与私网地址校验、最多 5 次人工处理的重定向。不可访问的 HN 原文只允许换成同一候选的严格 HN 讨论页;找不到同候选可访问备选时不发送日报。 - -## 文件与排查 - -```text -src/collectors/ 数据采集 -src/normalizers/ 统一数据结构 -src/ranking/ 百分位、聚类与热度 -src/digest/ 候选输入、Codex、校验与链接检查 -src/delivery/ 本人绑定、微信发送、锁与每日 ledger -src/app/ 采集/生成编排、健康检查与定时重试锁 -config/ 来源、排名、Codex 与审计清单 -prompts/ 日报提示词 -data/raw/ 每次原始与排名证据(Git 忽略) -data/history/ GitHub 快照与七日发送历史(Git 忽略) -output/ 日报 TXT/JSON(Git 忽略) -logs/ JSONL 日志(Git 忽略) -.state/ 微信、锁和发送状态(Git 忽略) -launchd/ 使用本机实际绝对路径的 LaunchAgent -``` - -所有项目文件写入都限制在项目根目录内,拒绝路径穿越和 symlink 逃逸。日志经过递归脱敏;QR 内容只交给终端渲染器及项目内私有临时 SVG,bot token、context token、Authorization、Cookie 和 Codex 凭据不会进入日报、日志、错误详情或 Git。 - -## 测试 - -在固定 Node 22 下执行: - -```sh -PATH=/opt/homebrew/opt/node@22/bin:/usr/bin:/bin \ - /opt/homebrew/opt/node@22/bin/node \ - /opt/homebrew/opt/node@22/lib/node_modules/npm/bin/npm-cli.js test - -/opt/homebrew/opt/node@22/bin/node node_modules/typescript/bin/tsc --noEmit -/opt/homebrew/opt/node@22/bin/node node_modules/typescript/bin/tsc --noEmit false --outDir dist -``` - -测试覆盖采集、单源降级、百分位与去重、GitHub 快照窗口、Codex 命令边界、提示词注入、输出结构、链接 SSRF 防护、微信本人绑定、旧消息拒绝、大整数消息 ID、分段、凭据 host 限制、vendor 产物篡改、并发锁、发送不确定态、每日幂等、定时重试边界和 LaunchAgent 结构。 - -## 定时任务状态 - -仓库中的 `launchd/com.kdnsna.daily-tech-digest.plist` 已通过 `plutil` 校验。 - -它使用当前用户和已检测到的绝对 Node 22、项目、构建产物与日志路径;每天本机时区 08:00 运行。当前 macOS 时区为 `Asia/Shanghai`,子进程也固定 `TZ=Asia/Shanghai`。`StartCalendarInterval` 在 Mac 休眠错过时间时会在唤醒后合并补跑一次,`RunAtLoad` 则覆盖首次加载或登录后重新加载;应用内的北京时间门槛会在 08:00 前返回 `not-due`,避免登录时早发。每日 ledger 保证当天已成功发送时不重复。全任务锁阻止并发,应用内部总共最多三次尝试(初次加两次重试),且只有明确可重试的失败才会重试。配置没有 `KeepAlive`,不会无限重启。 - -手动执行同一条定时流水线: - -```sh -./scripts/run-scheduled.sh -``` - -该脚本现在由 Observer 启动受审计的 `daily-tech-digest` 任务:日报投递完成后,再发送对应的文字运行报告和 PNG 卡片。如果上次 Observer 在任务运行期间被主机或进程中断,`interrupted` 报告只会在下一次 Observer 成功启动并完成恢复检查后上报。投递状态为 `indeterminate` 时永不自动重放,必须由运维人员核查,避免向微信重复发送。 - -新环境默认不会自动加载 LaunchAgent。安装脚本必须收到显式确认参数,否则退出且不改动 LaunchAgent: - -```sh -./scripts/install-launchd.sh --confirm-load -``` - -加载后可检查: - -```sh -/bin/launchctl print "gui/$(/usr/bin/id -u)/com.kdnsna.daily-tech-digest" -./scripts/health-check.sh -``` - -要停用已存在的 LaunchAgent,使用专用卸载脚本。它只停止任务并删除用户 `LaunchAgents` 中这一份精确 plist,不删除项目、日报、日志或微信状态: - -```sh -./scripts/uninstall-launchd.sh -``` - -标准输出与错误分别写入 `logs/launchd.stdout.log` 和 `logs/launchd.stderr.log`。业务过程的脱敏 JSONL 日志仍保存在 `logs/`;微信登录失效会单次退出并提示重新运行 `./scripts/init-wechat.sh`,不会无限重试。 - -## 安全与漏洞披露 - -请不要在公开 Issue 中提交漏洞细节、Token、Cookie、微信用户 ID、context token、二维码、日志或本机路径。安全问题请通过 GitHub 的[私密漏洞报告](https://github.com/kdnsna/WeChatPilot/security/advisories/new)提交;完整范围与报告要求见 [`SECURITY.md`](SECURITY.md)。 - -## 许可证与第三方组件 - -WeChatPilot 自有代码以 [MIT License](LICENSE) 开源。`vendor/cli-in-wechat` 是固定提交的独立 Git submodule,不属于 WeChatPilot 自有代码;其上游归属和使用边界见 [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) 与 [`vendor/README.md`](vendor/README.md)。WeChat、微信及相关标识归其权利人所有,本项目与微信或腾讯不存在隶属、授权或背书关系。 diff --git a/README.md b/README.md index 3daa5e2..2c5f9cf 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,16 @@ npm run wechatpilot -- doctor 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 --timezone Asia/Shanghai +npm run wechatpilot -- schedule install --hour 8 --minute 0 npm run wechatpilot -- schedule status npm run wechatpilot -- schedule remove ``` -`schedule install` 在安装时探测 Node/Codex,生成当前用户专属 plist。调度时间、时区和日志目录可通过参数调整。`doctor` 默认输出人类可读结果(`--json` 保留机器格式),且默认只读;只有显式 `--repair` 才会删除已确认进程死亡、inode 未变化且未被 `sending/indeterminate` ledger 阻止的陈旧锁。 +`schedule install` 在安装时探测 Node/Codex,生成当前用户专属 plist。v0.1 固定使用简体中文、`Asia/Shanghai`、每日 08:00 与固定栏目配额;LaunchAgent 日历触发使用机器系统时区,因此运行机器也必须设为 `Asia/Shanghai`。`doctor` 默认输出人类可读结果(`--json` 保留机器格式),且默认只读;只有显式 `--repair` 才会删除已确认进程死亡、inode 未变化且未被 `sending/indeterminate` ledger 阻止的陈旧锁。 ## 配置模型 -- `UserConfig`:`config/sources.yaml`、`ranking.yaml`、`digest.yaml` 与 observer 的时间、时区、语言、来源、配额和通知策略。 +- `UserConfig`:`config/sources.yaml`、`ranking.yaml`、`digest.yaml` 与 observer 的来源和通知策略;v0.1 的时区、语言、配额、栏目和篇幅为固定契约。 - `SecurityProfile`:代码中固定的 Codex 只读参数、禁用工具、stdin-only、输出上限、本人收件人约束和禁止参数。 - `ResolvedRuntime`:`wechatpilot init` 探测的 Node/Codex 绝对路径、版本与能力,写入私有 `.state/runtime.json`,不进入版本控制。 diff --git a/package-lock.json b/package-lock.json index 0b2f76a..98afde9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,73 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "@vitest/coverage-v8": "^4.1.10", "tsx": "^4.23.1", "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { - "node": ">=22 <23" + "node": ">=22" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/@emnapi/core": { @@ -502,6 +563,16 @@ "node": ">=18" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -509,6 +580,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1410,6 +1492,37 @@ "node": ">=16.20.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -1533,6 +1646,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -1680,6 +1805,69 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -1960,6 +2148,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -2110,6 +2326,19 @@ "node": ">=11.0.0" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2141,6 +2370,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index c6c7307..8495224 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "@vitest/coverage-v8": "^4.1.10", "tsx": "^4.23.1", "typescript": "^7.0.2", "vitest": "^4.1.10" diff --git a/scripts/fresh-clone-smoke.mjs b/scripts/fresh-clone-smoke.mjs index fbb6379..a9460a8 100644 --- a/scripts/fresh-clone-smoke.mjs +++ b/scripts/fresh-clone-smoke.mjs @@ -1,31 +1,17 @@ -import { mkdtempSync, rmSync, cpSync, existsSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; -const source = process.cwd(); const root = mkdtempSync(join(tmpdir(), "wechatpilot-fresh-")); try { - for (const name of [ - "package.json", - "package-lock.json", - "tsconfig.json", - "vitest.config.ts", - "src", - "config", - "prompts", - "scripts", - "tests", - "security", - "docs", - "launchd", - "AGENTS.md", - "README.md", - ]) { - if (existsSync(join(source, name))) { - cpSync(join(source, name), join(root, name), { recursive: true }); - } + const archive = spawnSync("git", ["archive", "HEAD"], { encoding: null }); + if (archive.status !== 0) { + console.error(archive.stderr?.toString("utf8")); + process.exit(archive.status ?? 1); } + const extract = spawnSync("tar", ["-x", "-C", root], { input: archive.stdout, encoding: "utf8" }); + if (extract.status !== 0) process.exit(extract.status ?? 1); const install = spawnSync("npm", ["ci"], { cwd: root, encoding: "utf8" }); if (install.status !== 0) { console.error(install.stdout, install.stderr); diff --git a/src/cli/wechatpilot.ts b/src/cli/wechatpilot.ts index 801747e..fa472f0 100644 --- a/src/cli/wechatpilot.ts +++ b/src/cli/wechatpilot.ts @@ -27,7 +27,7 @@ import { resolveLayoutRoots, } from "../layout/roots.js"; import { loadConfig } from "../config/load.js"; -import { resolveRuntime } from "../config/runtime.js"; +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"; @@ -65,8 +65,8 @@ function writeAtomic(path: string, content: string, mode: number): void { function initialize(root: string): object { const runtime = resolveRuntime(); - loadConfig(root); - loadObserverConfig(root); + loadConfig(root, runtime); + loadObserverConfig(root, runtime); loadEditorialConfig(root); const directory = join(root, ".state"); mkdirSync(directory, { recursive: true, mode: 0o700 }); @@ -136,9 +136,10 @@ function inspectLocks(root: string, repair: boolean): object { } function plist(root: string, hour: number, minute: number, timezone: string, logDirectory: string): string { - const runtime = resolveRuntime(); + const runtime = loadRuntimeManifest(root); const entrypoint = join(root, "dist", "src", "cli", "observer.js"); - return `\n\n\n Label${LABEL}\n ProgramArguments${xml(runtime.nodeExecutable)}${xml(entrypoint)}--project-root${xml(root)}--jobdaily-tech-digest\n WorkingDirectory${xml(root)}\n StartCalendarIntervalHour${hour}Minute${minute}\n RunAtLoadKeepAliveExitTimeOut30\n EnvironmentVariablesTZ${xml(timezone)}\n StandardOutPath${xml(join(logDirectory, "launchd.stdout.log"))}\n StandardErrorPath${xml(join(logDirectory, "launchd.stderr.log"))}\n\n`; + const manifest = join(root, ".state", "runtime.json"); + return `\n\n\n Label${LABEL}\n ProgramArguments${xml(runtime.nodeExecutable)}${xml(entrypoint)}--project-root${xml(root)}--jobdaily-tech-digest--runtime-manifest${xml(manifest)}\n WorkingDirectory${xml(root)}\n StartCalendarIntervalHour${hour}Minute${minute}\n RunAtLoadKeepAliveExitTimeOut30\n EnvironmentVariablesTZ${xml(timezone)}\n StandardOutPath${xml(join(logDirectory, "launchd.stdout.log"))}\n StandardErrorPath${xml(join(logDirectory, "launchd.stderr.log"))}\n\n`; } function schedulePath(): string { @@ -165,6 +166,9 @@ function schedule(arguments_: readonly string[], root: string): object { const minute = Number(option(arguments_, "--minute") ?? "0"); if (!Number.isInteger(hour) || hour < 0 || hour > 23 || !Number.isInteger(minute) || minute < 0 || minute > 59) throw new Error("Invalid schedule time"); const timezone = option(arguments_, "--timezone") ?? loadConfig(root).digest.timezone; + if (timezone !== "Asia/Shanghai") { + throw new Error("v0.1 supports only Asia/Shanghai scheduling"); + } const logDirectory = resolve(option(arguments_, "--log-dir") ?? join(root, "logs")); mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true, mode: 0o700 }); mkdirSync(logDirectory, { recursive: true, mode: 0o700 }); diff --git a/src/config/load.ts b/src/config/load.ts index fe14d8f..a6d479d 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -13,7 +13,7 @@ import { type RankingConfig, type SourcesConfig, } from "./schema.js"; -import { resolveRuntime, resolveRuntimePlaceholders } from "./runtime.js"; +import { loadRuntimeManifest, resolveRuntime, resolveRuntimePlaceholders, type ResolvedRuntime } from "./runtime.js"; import { loadSecurityProfile } from "./security-profile.js"; export interface ProjectConfig { @@ -34,9 +34,16 @@ function loadYaml(path: string, schema: ZodType, label: string): T { } } -export function loadConfig(projectRoot: string = process.cwd()): ProjectConfig { +export function loadConfig(projectRoot: string = process.cwd(), runtime?: ResolvedRuntime): ProjectConfig { const configDirectory = join(projectRoot, "config"); - const runtime = resolveRuntime(); + let resolvedRuntime = runtime; + if (resolvedRuntime === undefined) { + try { + resolvedRuntime = loadRuntimeManifest(projectRoot); + } catch { + resolvedRuntime = resolveRuntime(); + } + } const security = loadSecurityProfile(projectRoot); return { @@ -53,6 +60,6 @@ export function loadConfig(projectRoot: string = process.cwd()): ProjectConfig { digest: resolveRuntimePlaceholders(DigestConfigSchema.parse({ ...loadYaml(join(configDirectory, "digest.yaml"), DigestUserConfigSchema, "digest user"), codex: security.digestCodex, - }), runtime), + }), resolvedRuntime), }; } diff --git a/src/config/runtime.ts b/src/config/runtime.ts index a481441..1ff01a9 100644 --- a/src/config/runtime.ts +++ b/src/config/runtime.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { accessSync, constants, realpathSync } from "node:fs"; +import { accessSync, constants, lstatSync, readFileSync, realpathSync } from "node:fs"; import { delimiter, isAbsolute, join } from "node:path"; import { z } from "zod"; @@ -18,6 +18,15 @@ export const ResolvedRuntimeSchema = z.object({ export type ResolvedRuntime = z.infer; +export function loadRuntimeManifest(projectRoot: string): ResolvedRuntime { + const path = join(projectRoot, ".state", "runtime.json"); + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o600) { + throw new Error("Runtime manifest is invalid"); + } + return ResolvedRuntimeSchema.parse(JSON.parse(readFileSync(path, "utf8")) as unknown); +} + function pathExecutables(name: string, environment: NodeJS.ProcessEnv): string[] { const found: string[] = []; for (const directory of (environment.PATH ?? "").split(delimiter)) { diff --git a/src/config/schema.ts b/src/config/schema.ts index 76e24c8..b888cc6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -371,27 +371,24 @@ const codexConfigSchema = z export const DigestConfigSchema = z .object({ version: z.literal(1), - timezone: z.string().min(1).max(128), - language: z.enum(["zh-CN", "en"]).default("zh-CN"), + timezone: z.literal("Asia/Shanghai"), + language: z.literal("zh-CN"), windowHours: z.number().int().min(1).max(168), quotas: z .object({ - community: z.number().int().min(0).max(20), - news: z.number().int().min(0).max(20), - github: z.number().int().min(0).max(20), - deepReading: z.number().int().min(0).max(20), + community: z.literal(3), + news: z.literal(5), + github: z.literal(3), + deepReading: z.literal(2), }) .strict(), length: z .object({ unit: z.literal("chinese-characters"), - min: z.number().int().min(500).max(20_000), - max: z.number().int().min(501).max(20_000), + min: z.literal(2_000), + max: z.literal(3_000), }) - .strict() - .refine((length) => length.min < length.max, { - message: "digest minimum length must be lower than maximum length", - }), + .strict(), codex: codexConfigSchema, }) .strict(); diff --git a/src/observer/config.ts b/src/observer/config.ts index 24d6a41..bee63d0 100644 --- a/src/observer/config.ts +++ b/src/observer/config.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { parse } from "yaml"; import { z } from "zod"; -import { resolveRuntime, resolveRuntimePlaceholders } from "../config/runtime.js"; +import { loadRuntimeManifest, resolveRuntime, resolveRuntimePlaceholders, type ResolvedRuntime } from "../config/runtime.js"; export const ObserverJobSchema = z .object({ @@ -38,13 +38,13 @@ export interface ObserverConfig { readonly jobs: readonly [ObserverJob]; } -export function loadObserverConfig(projectRoot: string): ObserverConfig { +export function loadObserverConfig(projectRoot: string, runtime?: ResolvedRuntime): ObserverConfig { try { const path = join(projectRoot, "config", "observer-jobs.yaml"); const document = parse(readFileSync(path, "utf8")) as unknown; return resolveRuntimePlaceholders( ObserverConfigSchema.parse(document), - resolveRuntime(), + runtime ?? (() => { try { return loadRuntimeManifest(projectRoot); } catch { return resolveRuntime(); } })(), ); } catch { const error = new Error("Invalid observer configuration"); From 377e015aef291cf7b59d41b0aec167fa205d1821 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:02:00 +0800 Subject: [PATCH 07/14] fix: bind observer runtime manifest argument --- src/cli/observer.ts | 15 ++++++++++++--- tests/cli/observer.test.ts | 9 +++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/cli/observer.ts b/src/cli/observer.ts index 8b7c415..4d40684 100644 --- a/src/cli/observer.ts +++ b/src/cli/observer.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { @@ -45,7 +45,7 @@ function parseArguments(arguments_: readonly string[]): { readonly jobId: typeof FIXED_JOB; } { if ( - arguments_.length !== 4 || + (arguments_.length !== 4 && arguments_.length !== 6) || arguments_[0] !== "--project-root" || arguments_[2] !== "--job" || arguments_[3] !== FIXED_JOB @@ -56,7 +56,16 @@ function parseArguments(arguments_: readonly string[]): { if (projectRoot === undefined || projectRoot.trim() === "") { throw new ObserverCliUsageError(); } - return { projectRoot: resolve(projectRoot), jobId: FIXED_JOB }; + const resolvedRoot = resolve(projectRoot); + if ( + arguments_.length === 6 && + (arguments_[4] !== "--runtime-manifest" || + arguments_[5] === undefined || + resolve(arguments_[5]) !== join(resolvedRoot, ".state", "runtime.json")) + ) { + throw new ObserverCliUsageError(); + } + return { projectRoot: resolvedRoot, jobId: FIXED_JOB }; } function publicErrorName(error: unknown): string { diff --git a/tests/cli/observer.test.ts b/tests/cli/observer.test.ts index c17ddc7..cd978d9 100644 --- a/tests/cli/observer.test.ts +++ b/tests/cli/observer.test.ts @@ -53,6 +53,15 @@ describe("observer CLI", () => { ); }); + it("accepts only the project-local runtime manifest path", async () => { + const streams = io(); + const dependencies: ObserverCliDependencies = { run: vi.fn(async () => report()) }; + await expect(runObserverCli([ + "--project-root", ROOT, "--job", "daily-tech-digest", + "--runtime-manifest", `${ROOT}/.state/runtime.json`, + ], streams, dependencies)).resolves.toBe(0); + }); + it.each([ { arguments_: [] }, { arguments_: ["--project-root", ROOT] }, From 26dcd9ffafcdddb428cc1100051b74d49538e26b Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:34:08 +0800 Subject: [PATCH 08/14] fix: remove obsolete vendor gitlink --- .github/pull_request_template.md | 2 +- scripts/check-governance.mjs | 18 ++++++++++++++++++ scripts/fresh-clone-smoke.mjs | 21 ++++++++++++++++++++- vendor/cli-in-wechat | 1 - 4 files changed, 39 insertions(+), 3 deletions(-) delete mode 160000 vendor/cli-in-wechat diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e6179a9..5c52115 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,7 @@ - [ ] 不扩大微信单向发送边界 - [ ] 不引入凭据、Cookie、Token 或个人路径数据 -- [ ] 不修改 `cli-in-wechat` 固定提交,或已完成独立审计 +- [ ] 不重新引入 `cli-in-wechat`、其他上游控制入口或任何 gitlink/submodule - [ ] 不启用 Codex 高权限、危险沙箱参数或微信远程控制 ## 验证 diff --git a/scripts/check-governance.mjs b/scripts/check-governance.mjs index 4185f40..66d4e89 100644 --- a/scripts/check-governance.mjs +++ b/scripts/check-governance.mjs @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; const required = [ "LICENSE", @@ -22,4 +23,21 @@ if (missing.length > 0) { process.exit(1); } +const trackedFiles = spawnSync("git", ["ls-files", "--stage"], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, +}); +if (trackedFiles.status !== 0) { + console.error(trackedFiles.stderr); + process.exit(trackedFiles.status ?? 1); +} + +const gitlinks = trackedFiles.stdout + .split("\n") + .filter((line) => line.startsWith("160000 ")); +if (gitlinks.length > 0) { + console.error(`tracked gitlinks are not allowed in the release archive:\n${gitlinks.join("\n")}`); + process.exit(1); +} + console.log("governance files present"); diff --git a/scripts/fresh-clone-smoke.mjs b/scripts/fresh-clone-smoke.mjs index a9460a8..eef1bab 100644 --- a/scripts/fresh-clone-smoke.mjs +++ b/scripts/fresh-clone-smoke.mjs @@ -5,7 +5,26 @@ import { spawnSync } from "node:child_process"; const root = mkdtempSync(join(tmpdir(), "wechatpilot-fresh-")); try { - const archive = spawnSync("git", ["archive", "HEAD"], { encoding: null }); + const trackedFiles = spawnSync("git", ["ls-files", "--stage"], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + if (trackedFiles.status !== 0) { + console.error(trackedFiles.stderr); + process.exit(trackedFiles.status ?? 1); + } + const gitlinks = trackedFiles.stdout + .split("\n") + .filter((line) => line.startsWith("160000 ")); + if (gitlinks.length > 0) { + console.error(`fresh-clone archive contains unsupported gitlinks:\n${gitlinks.join("\n")}`); + process.exit(1); + } + + const archive = spawnSync("git", ["archive", "HEAD"], { + encoding: null, + maxBuffer: 64 * 1024 * 1024, + }); if (archive.status !== 0) { console.error(archive.stderr?.toString("utf8")); process.exit(archive.status ?? 1); diff --git a/vendor/cli-in-wechat b/vendor/cli-in-wechat deleted file mode 160000 index 75d7db9..0000000 --- a/vendor/cli-in-wechat +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 75d7db9338e133080fef9914b92ceb5e3ebd0dfc From 64ed298a4ca4030d58a29b8e52b67688f427d0af Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:49:07 +0800 Subject: [PATCH 09/14] fix: provide deterministic CI runtime --- .github/workflows/ci.yml | 8 ++++++++ scripts/ci-fake-codex.mjs | 9 +++++++++ 2 files changed, 17 insertions(+) create mode 100644 scripts/ci-fake-codex.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71116af..4b24b85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,14 @@ jobs: - name: Install root dependencies run: npm ci + - name: Prepare deterministic test runtime + run: | + test_runtime_dir="$RUNNER_TEMP/wechatpilot-test-runtime" + mkdir -p "$test_runtime_dir" + cp scripts/ci-fake-codex.mjs "$test_runtime_dir/codex" + chmod 755 "$test_runtime_dir/codex" + echo "$test_runtime_dir" >> "$GITHUB_PATH" + - name: Governance and portable-path checks run: | npm run check:governance diff --git a/scripts/ci-fake-codex.mjs b/scripts/ci-fake-codex.mjs new file mode 100644 index 0000000..a14dd8c --- /dev/null +++ b/scripts/ci-fake-codex.mjs @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +if (process.argv.length === 3 && process.argv[2] === "--version") { + process.stdout.write("codex-cli 0.0.0-ci\n"); + process.exit(0); +} + +process.stderr.write("The CI fake Codex supports only --version.\n"); +process.exit(64); From eda765dadd0a7da3db9e6641b0fb9c6c368e6d69 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:55:50 +0800 Subject: [PATCH 10/14] fix: quote CI transport guard safely --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b24b85..ca9b10f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Reject upstream remote-control imports run: | - if rg -n 'vendor/cli-in-wechat|bridge/router|adapters/registry|/dist/index|initProxyFromEnv|ProxyAgent|from ["'"']undici' src; then + if rg -n "vendor/cli-in-wechat|bridge/router|adapters/registry|/dist/index|initProxyFromEnv|ProxyAgent|from [\"']undici" src; then echo 'forbidden WeChat transport import detected' >&2 exit 1 fi From 6f89d796754ac59b7c495d6536fb157b5e512aca Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:01:43 +0800 Subject: [PATCH 11/14] fix: allow CodeQL workflow metadata read --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca9b10f..c5e6279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 permissions: + actions: read contents: read security-events: write steps: From 466b7b722c5afb28ff7834c022e767140860d61f Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:07:47 +0800 Subject: [PATCH 12/14] fix: keep private CodeQL analysis local --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e6279..2039f90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,3 +89,5 @@ jobs: uses: github/codeql-action/autobuild@ddf5ce7296213f5548c91e2dd19df2d77d2b2d66 # v3 - name: Perform CodeQL analysis uses: github/codeql-action/analyze@ddf5ce7296213f5548c91e2dd19df2d77d2b2d66 # v3 + with: + upload: ${{ github.event.repository.private && 'never' || 'always' }} From c83d4010258316fef787ce9610f8f62e36e82296 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:29:57 +0800 Subject: [PATCH 13/14] Enforce runtime manifests and canonical report delivery --- src/app/observer.ts | 49 ++++++++++++-- src/cli/observer.ts | 28 ++++++-- src/cli/wechatpilot.ts | 30 +++++++-- src/config/load.ts | 13 ++-- src/delivery/outbox.ts | 73 +++++++++++++++------ src/observer/config.ts | 4 +- src/observer/editorial-config.ts | 10 ++- tests/app/collect.test.ts | 2 + tests/app/digest.test.ts | 2 + tests/app/observer.test.ts | 62 ++++++++++++++++-- tests/cli/observer.test.ts | 12 +++- tests/cli/wechatpilot-schedule.test.ts | 66 +++++++++++++++++++ tests/config/config.test.ts | 37 +++++++++-- tests/delivery/outbox.test.ts | 85 +++++++++++++++++++++++++ tests/helpers/runtime.ts | 36 +++++++++++ tests/observer/codex-image.test.ts | 13 ++-- tests/observer/config.test.ts | 8 ++- tests/observer/editorial-config.test.ts | 13 +++- tests/observer/report-visual.test.ts | 19 +++--- 19 files changed, 484 insertions(+), 78 deletions(-) create mode 100644 tests/cli/wechatpilot-schedule.test.ts create mode 100644 tests/helpers/runtime.ts diff --git a/src/app/observer.ts b/src/app/observer.ts index 1523581..f2a98f8 100644 --- a/src/app/observer.ts +++ b/src/app/observer.ts @@ -80,6 +80,7 @@ import { } from "../observer/task-lock.js"; import { buildStatusEditorialReport } from "../report/editorial.js"; import { shanghaiDateKey } from "../lib/time.js"; +import type { ResolvedRuntime } from "../config/runtime.js"; const RECOVERY_SUMMARY = "任务因主机或进程中断,已在恢复后补记"; const RENDER_FAILURE_CODE = "OBSERVER_REPORT_RENDER_FAILED"; @@ -93,7 +94,10 @@ interface PrepareObserverSenderOptions { } export interface ObserverDependencies { - readonly loadConfig?: (projectRoot: string) => ObserverConfig; + readonly loadConfig?: ( + projectRoot: string, + runtime?: ResolvedRuntime, + ) => ObserverConfig; readonly recover?: ( options: RecoverInterruptedRunsOptions, ) => RecoverInterruptedRunsResult; @@ -136,6 +140,7 @@ export interface ObserverDependencies { export interface RunObserverJobOptions { readonly projectRoot: string; readonly jobId: "daily-tech-digest"; + readonly runtime?: ResolvedRuntime; readonly signal?: AbortSignal; readonly environment?: NodeJS.ProcessEnv; readonly now?: () => Date; @@ -147,7 +152,10 @@ export interface RunObserverJobOptions { } interface ResolvedObserverDependencies { - readonly loadConfig: (projectRoot: string) => ObserverConfig; + readonly loadConfig: ( + projectRoot: string, + runtime?: ResolvedRuntime, + ) => ObserverConfig; readonly recover: ( options: RecoverInterruptedRunsOptions, ) => RecoverInterruptedRunsResult; @@ -163,7 +171,10 @@ interface ResolvedObserverDependencies { readonly runJob: (options: RunManagedJobOptions) => Promise; readonly normalize: (options: BuildRunReportOptions) => RunReport; readonly finishRecord: (projectRoot: string, report: RunReport) => void; - readonly loadEditorial: (projectRoot: string) => EditorialConfig; + readonly loadEditorial: ( + projectRoot: string, + runtime?: ResolvedRuntime, + ) => EditorialConfig; readonly assignIssue: (options: { readonly projectRoot: string; readonly runId: string; @@ -300,7 +311,10 @@ async function prepareFinalReportArtifacts( report: RunReport, disposition: RunDeliveryDisposition, ): Promise { - const imageConfig = dependencies.loadEditorial(options.projectRoot); + const imageConfig = dependencies.loadEditorial( + options.projectRoot, + options.runtime, + ); const mode = visualMode(imageConfig); if (mode === "off") { @@ -458,6 +472,26 @@ function shouldNotify(job: ObserverJob, report: RunReport): boolean { return report.status !== "success" && report.status !== "skipped"; } +function notificationDisposition( + job: ObserverJob, +): "not-required" | "notification-disabled" { + return job.notify === "never" ? "notification-disabled" : "not-required"; +} + +function reconcileSuppressedReport( + options: RunObserverJobOptions, + dependencies: ResolvedObserverDependencies, + report: RunReport, + job: ObserverJob, +): void { + dependencies.recordNotificationDisposition({ + projectRoot: options.projectRoot, + report, + disposition: notificationDisposition(job), + ...(options.now === undefined ? {} : { now: options.now }), + }); +} + async function queueHistoricalReports( options: RunObserverJobOptions, dependencies: ResolvedObserverDependencies, @@ -473,6 +507,7 @@ async function queueHistoricalReports( continue; } if (!shouldNotify(reportJob, report)) { + reconcileSuppressedReport(options, dependencies, report, reportJob); continue; } const disposition = dependencies.deliveryDisposition({ @@ -497,6 +532,7 @@ async function queueHistoricalReports( continue; } if (!shouldNotify(reportJob, report)) { + reconcileSuppressedReport(options, dependencies, report, reportJob); continue; } const disposition = dependencies.deliveryDisposition({ @@ -560,7 +596,10 @@ export async function runObserverJob( options: RunObserverJobOptions, ): Promise { const dependencies = resolvedDependencies(options.dependencies); - const config = dependencies.loadConfig(options.projectRoot); + const config = dependencies.loadConfig( + options.projectRoot, + options.runtime, + ); const job = findObserverJob(config, options.jobId); const recovery = dependencies.recover({ projectRoot: options.projectRoot, diff --git a/src/cli/observer.ts b/src/cli/observer.ts index 4d40684..1a75396 100644 --- a/src/cli/observer.ts +++ b/src/cli/observer.ts @@ -5,6 +5,7 @@ import { runObserverJob, type RunObserverJobOptions, } from "../app/observer.js"; +import { loadRuntimeManifest, type ResolvedRuntime } from "../config/runtime.js"; import { RunReportDeliveryBlockedError, RunReportDeliveryBusyError, @@ -29,6 +30,7 @@ export interface ObserverCliIo { export interface ObserverCliDependencies { readonly run?: (options: RunObserverJobOptions) => ReturnType; + readonly loadRuntime?: (projectRoot: string) => ResolvedRuntime; } class ObserverCliUsageError extends Error { @@ -43,6 +45,7 @@ const processIo: ObserverCliIo = { function parseArguments(arguments_: readonly string[]): { readonly projectRoot: string; readonly jobId: typeof FIXED_JOB; + readonly runtimeManifestPath?: string; } { if ( (arguments_.length !== 4 && arguments_.length !== 6) || @@ -57,13 +60,19 @@ function parseArguments(arguments_: readonly string[]): { throw new ObserverCliUsageError(); } const resolvedRoot = resolve(projectRoot); - if ( - arguments_.length === 6 && - (arguments_[4] !== "--runtime-manifest" || + if (arguments_.length === 6) { + if ( + arguments_[4] !== "--runtime-manifest" || arguments_[5] === undefined || - resolve(arguments_[5]) !== join(resolvedRoot, ".state", "runtime.json")) - ) { - throw new ObserverCliUsageError(); + resolve(arguments_[5]) !== join(resolvedRoot, ".state", "runtime.json") + ) { + throw new ObserverCliUsageError(); + } + return { + projectRoot: resolvedRoot, + jobId: FIXED_JOB, + runtimeManifestPath: join(resolvedRoot, ".state", "runtime.json"), + }; } return { projectRoot: resolvedRoot, jobId: FIXED_JOB }; } @@ -97,9 +106,16 @@ export async function runObserverCli( ): Promise<0 | 1> { try { const parsed = parseArguments(arguments_); + // Manifest is an execution input, not a decorative argument. + const loadRuntime = dependencies.loadRuntime ?? loadRuntimeManifest; + const runtime: ResolvedRuntime | undefined = + parsed.runtimeManifestPath === undefined + ? undefined + : loadRuntime(parsed.projectRoot); const report = await (dependencies.run ?? runObserverJob)({ projectRoot: parsed.projectRoot, jobId: parsed.jobId, + ...(runtime === undefined ? {} : { runtime }), ...(signal === undefined ? {} : { signal }), }); io.stdout( diff --git a/src/cli/wechatpilot.ts b/src/cli/wechatpilot.ts index fa472f0..68f8ab5 100644 --- a/src/cli/wechatpilot.ts +++ b/src/cli/wechatpilot.ts @@ -34,6 +34,23 @@ import { buildStabilityReview } from "../observer/stability-review.js"; const LABEL = "io.wechatpilot.daily-tech-digest"; +function hostTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +function assertFixedSchedule(hour: number, minute: number, timezone: string): void { + if (hour !== 8 || minute !== 0) { + throw new Error("v0.1 supports only 08:00 Asia/Shanghai scheduling"); + } + if (timezone !== "Asia/Shanghai") { + throw new Error("v0.1 supports only Asia/Shanghai scheduling"); + } + if (hostTimezone() !== "Asia/Shanghai") { + throw new Error("Host system timezone must be Asia/Shanghai for v0.1 LaunchAgent scheduling"); + } +} + + function xml(value: string): string { return value .replaceAll("&", "&") @@ -67,7 +84,7 @@ function initialize(root: string): object { const runtime = resolveRuntime(); loadConfig(root, runtime); loadObserverConfig(root, runtime); - loadEditorialConfig(root); + loadEditorialConfig(root, runtime); const directory = join(root, ".state"); mkdirSync(directory, { recursive: true, mode: 0o700 }); chmodSync(directory, 0o700); @@ -165,10 +182,8 @@ function schedule(arguments_: readonly string[], root: string): object { const hour = Number(option(arguments_, "--hour") ?? "8"); const minute = Number(option(arguments_, "--minute") ?? "0"); if (!Number.isInteger(hour) || hour < 0 || hour > 23 || !Number.isInteger(minute) || minute < 0 || minute > 59) throw new Error("Invalid schedule time"); - const timezone = option(arguments_, "--timezone") ?? loadConfig(root).digest.timezone; - if (timezone !== "Asia/Shanghai") { - throw new Error("v0.1 supports only Asia/Shanghai scheduling"); - } + const timezone = option(arguments_, "--timezone") ?? "Asia/Shanghai"; + assertFixedSchedule(hour, minute, timezone); const logDirectory = resolve(option(arguments_, "--log-dir") ?? join(root, "logs")); mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true, mode: 0o700 }); mkdirSync(logDirectory, { recursive: true, mode: 0o700 }); @@ -185,6 +200,11 @@ export async function runWechatPilot(arguments_: readonly string[]): Promise(path: string, schema: ZodType, label: string): T { export function loadConfig(projectRoot: string = process.cwd(), runtime?: ResolvedRuntime): ProjectConfig { const configDirectory = join(projectRoot, "config"); - let resolvedRuntime = runtime; - if (resolvedRuntime === undefined) { - try { - resolvedRuntime = loadRuntimeManifest(projectRoot); - } catch { - resolvedRuntime = resolveRuntime(); - } - } + // Runtime discovery is reserved for init/doctor repair. Normal loads use the + // authoritative project manifest or an explicitly supplied runtime. + const resolvedRuntime = runtime ?? loadRuntimeManifest(projectRoot); const security = loadSecurityProfile(projectRoot); return { diff --git a/src/delivery/outbox.ts b/src/delivery/outbox.ts index 55b45b7..0d75d59 100644 --- a/src/delivery/outbox.ts +++ b/src/delivery/outbox.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { readdirSync } from "node:fs"; import { z } from "zod"; @@ -21,6 +20,7 @@ import { RunReportSchema } from "../observer/run-report.js"; import { deliverRunReport, runDeliveryDisposition, + runReportSha256, type DeliverRunReportOptions, type DeliverRunReportResult, } from "./run-report-delivery.js"; @@ -37,7 +37,23 @@ const OutboxItemSchema = z imagePath: z.string().min(1).max(4_096).optional(), lastErrorClass: z.string().min(1).max(128).optional(), }) - .strict(); + .strict() + .superRefine((item, context) => { + if (item.envelope.id !== item.report.runId) { + context.addIssue({ + code: "custom", + path: ["envelope", "id"], + message: "outbox id must match report.runId", + }); + } + if (item.envelope.payloadSha256 !== runReportSha256(item.report)) { + context.addIssue({ + code: "custom", + path: ["envelope", "payloadSha256"], + message: "outbox payload hash must match canonical report", + }); + } + }); export type OutboxItem = z.infer; @@ -83,12 +99,6 @@ function itemPath(projectRoot: string, id: string): string { ); } -function stableHash(value: unknown): string { - return createHash("sha256") - .update(JSON.stringify(value), "utf8") - .digest("hex"); -} - export function enqueueRunReportOutbox(options: { readonly projectRoot: string; readonly report: RunReport; @@ -99,9 +109,9 @@ export function enqueueRunReportOutbox(options: { const report = RunReportSchema.parse(options.report); const now = (options.now ?? (() => new Date()))(); const id = report.runId; + const payloadSha256 = runReportSha256(report); const existing = readItem(options.projectRoot, id); if (existing !== null) { - const payloadSha256 = stableHash(report); if (existing.envelope.payloadSha256 === payloadSha256) { return existing; } @@ -130,7 +140,7 @@ export function enqueueRunReportOutbox(options: { version: 1, id, channel: "run-report-text", - payloadSha256: stableHash(report), + payloadSha256, createdAt: now.toISOString(), priority: options.priority ?? (report.status === "success" ? 100 : 50), attempt: 0, @@ -285,6 +295,17 @@ export async function processRunReportOutbox( sent += 1; continue; } + if ( + disposition === "not-required" || + disposition === "notification-disabled" + ) { + writeItem(options.projectRoot, { + ...item, + envelope: { ...item.envelope, disposition: "sent" }, + }); + sent += 1; + continue; + } if (disposition === "indeterminate" || disposition === "blocked") { writeItem(options.projectRoot, { ...item, @@ -308,13 +329,15 @@ export async function processRunReportOutbox( : { imagePath: item.imagePath }), } : await options.prepare(item); + const preparedReport = RunReportSchema.parse(prepared.report); + const preparedHash = runReportSha256(preparedReport); let sendText = options.sendText; let sendImage = options.sendImage; if (options.createSenders !== undefined) { const senders = await options.createSenders({ ...item, - report: prepared.report, + report: preparedReport, ...(prepared.imagePath === undefined ? {} : { imagePath: prepared.imagePath }), @@ -327,9 +350,25 @@ export async function processRunReportOutbox( continue; } + // Persist the canonical report/hash before any network send so retries + // share the same payload identity as the delivery ledger. + const preparedItem: OutboxItem = { + ...item, + report: preparedReport, + ...(prepared.imagePath === undefined + ? {} + : { imagePath: prepared.imagePath }), + envelope: { + ...item.envelope, + id: preparedReport.runId, + payloadSha256: preparedHash, + }, + }; + writeItem(options.projectRoot, preparedItem); + await deliver({ projectRoot: options.projectRoot, - report: prepared.report, + report: preparedReport, sendText, sendImage, ...(prepared.imagePath === undefined @@ -338,14 +377,10 @@ export async function processRunReportOutbox( now, }); writeItem(options.projectRoot, { - ...item, - report: prepared.report, - ...(prepared.imagePath === undefined - ? {} - : { imagePath: prepared.imagePath }), + ...preparedItem, envelope: { - ...item.envelope, - attempt: item.envelope.attempt + 1, + ...preparedItem.envelope, + attempt: preparedItem.envelope.attempt + 1, disposition: "sent", }, }); diff --git a/src/observer/config.ts b/src/observer/config.ts index bee63d0..f3e7287 100644 --- a/src/observer/config.ts +++ b/src/observer/config.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { parse } from "yaml"; import { z } from "zod"; -import { loadRuntimeManifest, resolveRuntime, resolveRuntimePlaceholders, type ResolvedRuntime } from "../config/runtime.js"; +import { loadRuntimeManifest, resolveRuntimePlaceholders, type ResolvedRuntime } from "../config/runtime.js"; export const ObserverJobSchema = z .object({ @@ -44,7 +44,7 @@ export function loadObserverConfig(projectRoot: string, runtime?: ResolvedRuntim const document = parse(readFileSync(path, "utf8")) as unknown; return resolveRuntimePlaceholders( ObserverConfigSchema.parse(document), - runtime ?? (() => { try { return loadRuntimeManifest(projectRoot); } catch { return resolveRuntime(); } })(), + runtime ?? loadRuntimeManifest(projectRoot), ); } catch { const error = new Error("Invalid observer configuration"); diff --git a/src/observer/editorial-config.ts b/src/observer/editorial-config.ts index f7d80c3..8a125a4 100644 --- a/src/observer/editorial-config.ts +++ b/src/observer/editorial-config.ts @@ -8,7 +8,7 @@ import { safeCodexEnvironment, type CodexInvocation, } from "../digest/codex.js"; -import { resolveRuntime, resolveRuntimePlaceholders } from "../config/runtime.js"; +import { loadRuntimeManifest, resolveRuntimePlaceholders, type ResolvedRuntime } from "../config/runtime.js"; import { loadSecurityProfile } from "../config/security-profile.js"; const EXPECTED_GLOBAL_ARGS = [ @@ -180,7 +180,10 @@ function validateOutputSchema(projectRoot: string): void { } } -export function loadEditorialConfig(projectRoot: string): EditorialConfig { +export function loadEditorialConfig( + projectRoot: string, + runtime?: ResolvedRuntime, +): EditorialConfig { try { if (!isAbsolute(projectRoot)) { throw new TypeError("project root must be absolute"); @@ -204,12 +207,13 @@ export function loadEditorialConfig(projectRoot: string): EditorialConfig { } as EditorialConfig; } const security = loadSecurityProfile(projectRoot); + const resolvedRuntime = runtime ?? loadRuntimeManifest(projectRoot); const config = resolveRuntimePlaceholders( EditorialConfigSchema.parse({ ...user, codex: security.imageCodex, }), - resolveRuntime(), + resolvedRuntime, ); if (user.mode === "editorial") { validateOutputSchema(projectRoot); diff --git a/tests/app/collect.test.ts b/tests/app/collect.test.ts index 99b38cc..2a336cf 100644 --- a/tests/app/collect.test.ts +++ b/tests/app/collect.test.ts @@ -9,6 +9,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; +import { writeRuntimeManifest } from "../helpers/runtime.js"; import { runDailyCollection } from "../../src/app/collect.js"; import type { HttpClient } from "../../src/lib/http.js"; @@ -35,6 +36,7 @@ function root(): string { ]) { copyFileSync(join(repositoryRoot, "config", name), join(project, "config", name)); } + writeRuntimeManifest(project); return project; } diff --git a/tests/app/digest.test.ts b/tests/app/digest.test.ts index 1b32a2a..704085d 100644 --- a/tests/app/digest.test.ts +++ b/tests/app/digest.test.ts @@ -15,6 +15,7 @@ import type { RegisteredCollector } from "../../src/collectors/run.js"; import type { HttpClient } from "../../src/lib/http.js"; import { Category, type NormalizedItem } from "../../src/types/item.js"; import { validEditorial } from "../helpers/editorial.js"; +import { writeRuntimeManifest } from "../helpers/runtime.js"; const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); const NOW = new Date("2026-07-18T06:00:00.000Z"); @@ -42,6 +43,7 @@ function project(): string { join(repositoryRoot, "prompts", "daily-digest.md"), join(root, "prompts", "daily-digest.md"), ); + writeRuntimeManifest(root); return root; } diff --git a/tests/app/observer.test.ts b/tests/app/observer.test.ts index f5992d2..9cf727e 100644 --- a/tests/app/observer.test.ts +++ b/tests/app/observer.test.ts @@ -580,21 +580,22 @@ describe("observer orchestration", () => { }); it.each([ - ["on-failure", ["failed"], 1], - ["never", [], 0], + ["on-failure", ["failed"], 1, "not-required"], + ["never", [], 0, "notification-disabled"], ] as const)( "does not enqueue intentionally suppressed historical reports for %s", - async (notify, expectedStatuses, expectedEnqueues) => { + async (notify, expectedStatuses, expectedEnqueues, disposition) => { const successful = report("018f514c-1a2b-7abc-8def-1234567890d1", "success"); const failed = report("018f514c-1a2b-7abc-8def-1234567890d2", "failed"); const enqueueOutbox = vi.fn(dependencies().enqueueOutbox); + const recordNotificationDisposition = vi.fn(); const policyJob = { ...job, notify }; const deps = dependencies({ loadConfig: vi.fn(() => ({ version: 1 as const, jobs: [policyJob] } as ObserverConfig)), listFinalReports: vi.fn(() => [successful, failed]), deliveryDisposition: vi.fn(() => "missing" as const), enqueueOutbox, - recordNotificationDisposition: vi.fn(), + recordNotificationDisposition, runJob: vi.fn(async () => ({ authority: authority(CURRENT_ID, "success"), draft: null, @@ -611,9 +612,62 @@ describe("observer orchestration", () => { expect(enqueueOutbox.mock.calls.map(([options]) => options.report.status)).toEqual( expectedStatuses, ); + const historicalDispositions = recordNotificationDisposition.mock.calls + .map(([options]) => options) + .filter((options) => options.report.runId !== CURRENT_ID) + .map((options) => [options.report.status, options.disposition]); + if (notify === "on-failure") { + expect(historicalDispositions).toEqual([["success", disposition]]); + } else { + expect(historicalDispositions).toEqual([ + ["success", disposition], + ["failed", disposition], + ]); + } }, ); + it("backfills suppression on a second start when the final report already exists", async () => { + const successful = report("018f514c-1a2b-7abc-8def-1234567890d3", "success"); + const recordNotificationDisposition = vi.fn(); + const policyJob = { ...job, notify: "on-failure" as const }; + const shared = { + loadConfig: vi.fn(() => ({ version: 1 as const, jobs: [policyJob] } as ObserverConfig)), + listFinalReports: vi.fn(() => [successful]), + deliveryDisposition: vi.fn(() => "missing" as const), + enqueueOutbox: vi.fn(dependencies().enqueueOutbox), + recordNotificationDisposition, + // Keep the current run suppressed as well so the assertion isolates + // historical reconciliation rather than current delivery side effects. + runJob: vi.fn(async () => ({ + authority: authority(CURRENT_ID, "success"), + draft: null, + })), + }; + + await runObserverJob({ + projectRoot: ROOT, + jobId: policyJob.id, + dependencies: dependencies(shared), + }); + await runObserverJob({ + projectRoot: ROOT, + jobId: policyJob.id, + dependencies: dependencies(shared), + }); + + expect(shared.enqueueOutbox).not.toHaveBeenCalled(); + const historicalCalls = recordNotificationDisposition.mock.calls + .map(([options]) => options) + .filter((options) => options.report.runId === successful.runId); + expect(historicalCalls).toHaveLength(2); + expect(historicalCalls[0]).toEqual({ + projectRoot: ROOT, + report: successful, + disposition: "not-required", + }); + }); + it.each([ ["on-failure", "success", "not-required"], ["never", "failed", "notification-disabled"], diff --git a/tests/cli/observer.test.ts b/tests/cli/observer.test.ts index cd978d9..435254c 100644 --- a/tests/cli/observer.test.ts +++ b/tests/cli/observer.test.ts @@ -5,6 +5,7 @@ import { type ObserverCliDependencies, } from "../../src/cli/observer.js"; import type { RunReport } from "../../src/observer/run-report.js"; +import { fakeRuntime } from "../helpers/runtime.js"; const ROOT = "/private/tmp/wechat-observer-cli"; const RUN_ID = "018f514c-1a2b-7abc-8def-1234567890ac"; @@ -55,11 +56,20 @@ describe("observer CLI", () => { it("accepts only the project-local runtime manifest path", async () => { const streams = io(); - const dependencies: ObserverCliDependencies = { run: vi.fn(async () => report()) }; + const runtime = fakeRuntime(); + const run = vi.fn(async () => report()); + const loadRuntime = vi.fn(() => runtime); + const dependencies: ObserverCliDependencies = { run, loadRuntime }; await expect(runObserverCli([ "--project-root", ROOT, "--job", "daily-tech-digest", "--runtime-manifest", `${ROOT}/.state/runtime.json`, ], streams, dependencies)).resolves.toBe(0); + expect(loadRuntime).toHaveBeenCalledWith(ROOT); + expect(run).toHaveBeenCalledWith(expect.objectContaining({ + projectRoot: ROOT, + jobId: "daily-tech-digest", + runtime, + })); }); it.each([ diff --git a/tests/cli/wechatpilot-schedule.test.ts b/tests/cli/wechatpilot-schedule.test.ts new file mode 100644 index 0000000..df034cd --- /dev/null +++ b/tests/cli/wechatpilot-schedule.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { runWechatPilot } from "../../src/cli/wechatpilot.js"; + +const originalResolved = Intl.DateTimeFormat.prototype.resolvedOptions; + +afterEach(() => { + Intl.DateTimeFormat.prototype.resolvedOptions = originalResolved; +}); + +function mockHostTimezone(timeZone: string): void { + Intl.DateTimeFormat.prototype.resolvedOptions = function resolvedOptions( + this: Intl.DateTimeFormat, + ) { + return { ...originalResolved.call(this), timeZone }; + } as typeof originalResolved; +} + +describe("wechatpilot schedule install contract", () => { + it.each([ + ["--hour", "7"], + ["--minute", "30"], + ] as const)("rejects non-fixed schedule flag %s %s", async (flag, value) => { + mockHostTimezone("Asia/Shanghai"); + await expect( + runWechatPilot([ + "schedule", + "install", + "--project-root", + process.cwd(), + flag, + value, + ]), + ).rejects.toThrow(/08:00 Asia\/Shanghai/i); + }); + + it("rejects a non-Shanghai host timezone", async () => { + mockHostTimezone("UTC"); + await expect( + runWechatPilot([ + "schedule", + "install", + "--project-root", + process.cwd(), + "--hour", + "8", + "--minute", + "0", + ]), + ).rejects.toThrow(/Host system timezone must be Asia\/Shanghai/i); + }); + + it("rejects a non-Shanghai requested timezone", async () => { + mockHostTimezone("Asia/Shanghai"); + await expect( + runWechatPilot([ + "schedule", + "install", + "--project-root", + process.cwd(), + "--timezone", + "UTC", + ]), + ).rejects.toThrow(/Asia\/Shanghai/i); + }); +}); diff --git a/tests/config/config.test.ts b/tests/config/config.test.ts index 35015d4..59f29ca 100644 --- a/tests/config/config.test.ts +++ b/tests/config/config.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, copyFileSync, mkdirSync, mkdtempSync, @@ -13,6 +14,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { loadConfig } from "../../src/config/load.js"; +import { fakeRuntime, writeRuntimeManifest } from "../helpers/runtime.js"; const projectRoot = fileURLToPath(new URL("../..", import.meta.url)); const temporaryRoots: string[] = []; @@ -36,6 +38,7 @@ function copyConfigFixture(): string { for (const name of ["sources.yaml", "ranking.yaml", "digest.yaml", "security-profile.yaml"]) { copyFileSync(join(projectRoot, "config", name), join(configDirectory, name)); } + writeRuntimeManifest(root); temporaryRoots.push(root); return root; @@ -76,7 +79,7 @@ describe("loadConfig", () => { }); it("loads and validates all three project YAML configurations", () => { - const config = loadConfig(projectRoot); + const config = loadConfig(projectRoot, fakeRuntime()); expect(config.sources.version).toBe(1); expect(config.ranking.version).toBe(1); @@ -97,7 +100,7 @@ describe("loadConfig", () => { }); it("keeps ranking formula weights normalized to one", () => { - const { weights } = loadConfig(projectRoot).ranking.formula; + const { weights } = loadConfig(projectRoot, fakeRuntime()).ranking.formula; const total = Object.values(weights).reduce((sum, weight) => sum + weight, 0); expect(total).toBeCloseTo(1, 12); @@ -109,14 +112,14 @@ describe("loadConfig", () => { crossSource: 0.12, sourceQuality: 0.08, }); - expect(loadConfig(projectRoot).ranking.history).toEqual({ + expect(loadConfig(projectRoot, fakeRuntime()).ranking.history).toEqual({ lookbackDays: 7, repeatFactor: 0.35, }); }); it("contains every required source group and keeps Reddit optional", () => { - const { sources } = loadConfig(projectRoot).sources; + const { sources } = loadConfig(projectRoot, fakeRuntime()).sources; expect(Object.keys(sources)).toEqual([ "hackerNews", @@ -181,7 +184,7 @@ describe("loadConfig", () => { }); it("resolves the detected Codex runtime while preserving the verified security profile", () => { - const { codex } = loadConfig(projectRoot).digest; + const { codex } = loadConfig(projectRoot, fakeRuntime()).digest; expect(codex.nodeExecutable).toBe(realpathSync(process.execPath)); expect(codex.javascriptEntrypoint).toMatch(/^\//u); @@ -405,3 +408,27 @@ describe("loadConfig", () => { }, ); }); + +describe("authoritative runtime manifest", () => { + it("fails closed when the runtime manifest is missing", () => { + const root = copyConfigFixture(); + rmSync(join(root, ".state"), { recursive: true, force: true }); + expect(() => loadConfig(root)).toThrow(/runtime manifest|ENOENT|no such file|invalid/i); + }); + + it("fails closed when the runtime manifest mode is not 0600", () => { + const root = copyConfigFixture(); + const manifest = join(root, ".state", "runtime.json"); + chmodSync(manifest, 0o644); + expect(() => loadConfig(root)).toThrow(/runtime manifest/i); + }); + + it("uses the supplied runtime without PATH discovery", () => { + const root = copyConfigFixture(); + const usable = fakeRuntime(); + const config = loadConfig(root, usable); + expect(config.digest.codex.nodeExecutable).toBe(usable.nodeExecutable); + expect(config.digest.codex.javascriptEntrypoint).toBe(usable.codexEntrypoint); + }); +}); + diff --git a/tests/delivery/outbox.test.ts b/tests/delivery/outbox.test.ts index 62729e0..ce3ee5a 100644 --- a/tests/delivery/outbox.test.ts +++ b/tests/delivery/outbox.test.ts @@ -9,6 +9,10 @@ import { inspectOutbox, processRunReportOutbox, } from "../../src/delivery/outbox.js"; +import { + recordRunReportNotificationDisposition, + runReportSha256, +} from "../../src/delivery/run-report-delivery.js"; import type { RunReport } from "../../src/observer/run-report.js"; const roots: string[] = []; @@ -121,5 +125,86 @@ describe("delivery outbox", () => { expect(item.report).toEqual(canonical); expect(item.envelope.payloadSha256).not.toBe(initialItem.envelope.payloadSha256); + expect(item.envelope.payloadSha256).toBe(runReportSha256(canonical)); + }); + + it("uses the canonical report hash shared with the delivery ledger", () => { + const root = project(); + const value = report("018f514c-1a2b-7abc-8def-1234567890d1"); + const item = enqueueRunReportOutbox({ projectRoot: root, report: value }); + expect(item.envelope.payloadSha256).toBe(runReportSha256(value)); + expect(item.envelope.id).toBe(value.runId); + }); + + it("treats notification suppression as terminal and never creates senders", async () => { + const root = project(); + const value = report("018f514c-1a2b-7abc-8def-1234567890d2", "success"); + const item = enqueueRunReportOutbox({ projectRoot: root, report: value }); + chmodSync(join(root, ".state", "delivery", "outbox", `${item.envelope.id}.json`), 0o600); + recordRunReportNotificationDisposition({ + projectRoot: root, + report: value, + disposition: "not-required", + }); + const createSenders = vi.fn(async () => ({ + sendText: async () => ({ status: "sent" as const, chunks: 1 }), + sendImage: async () => ({ status: "sent" as const }), + })); + const deliver = vi.fn(async () => ({ + status: "sent" as const, + textChunks: 1, + image: "not-generated" as const, + textAttempt: 1, + imageAttempt: 0, + })); + const result = await processRunReportOutbox({ + projectRoot: root, + historyLimit: 3, + deliver, + createSenders, + }); + expect(result.sent).toBe(1); + expect(createSenders).not.toHaveBeenCalled(); + expect(deliver).not.toHaveBeenCalled(); + const stored = JSON.parse( + readFileSync(join(root, ".state", "delivery", "outbox", `${item.envelope.id}.json`), "utf8"), + ) as { envelope: { disposition: string; payloadSha256: string } }; + expect(stored.envelope.disposition).toBe("sent"); + expect(stored.envelope.payloadSha256).toBe(runReportSha256(value)); + }); + + it("recomputes payload hash after prepare mutates the canonical report", async () => { + const root = project(); + const initial = report("018f514c-1a2b-7abc-8def-1234567890d3"); + const item = enqueueRunReportOutbox({ projectRoot: root, report: initial }); + chmodSync(join(root, ".state", "delivery", "outbox", `${item.envelope.id}.json`), 0o600); + const numbered: RunReport = { + ...initial, + issueNumber: 9, + issueLabel: "WP-000009", + }; + await processRunReportOutbox({ + projectRoot: root, + historyLimit: 3, + prepare: async () => ({ report: numbered, imagePath: "/tmp/wp-9.png" }), + deliver: async () => ({ + status: "sent" as const, + textChunks: 1, + image: "sent" as const, + textAttempt: 1, + imageAttempt: 1, + }), + sendText: async () => ({ status: "sent", chunks: 1 }), + sendImage: async () => ({ status: "sent" }), + }); + const stored = JSON.parse( + readFileSync(join(root, ".state", "delivery", "outbox", `${item.envelope.id}.json`), "utf8"), + ) as { + report: RunReport; + envelope: { payloadSha256: string; disposition: string }; + }; + expect(stored.report).toEqual(numbered); + expect(stored.envelope.payloadSha256).toBe(runReportSha256(numbered)); + expect(stored.envelope.disposition).toBe("sent"); }); }); diff --git a/tests/helpers/runtime.ts b/tests/helpers/runtime.ts new file mode 100644 index 0000000..b83cfec --- /dev/null +++ b/tests/helpers/runtime.ts @@ -0,0 +1,36 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { realpathSync } from "node:fs"; + +import type { ResolvedRuntime } from "../../src/config/runtime.js"; + +export function fakeRuntime( + overrides: Partial = {}, +): ResolvedRuntime { + const nodeExecutable = realpathSync(process.execPath); + return { + version: 1, + nodeExecutable, + nodeVersion: process.version, + codexExecutable: nodeExecutable, + codexEntrypoint: nodeExecutable, + codexVersion: "0.0.0-test", + ...overrides, + }; +} + +export function writeRuntimeManifest( + projectRoot: string, + runtime: ResolvedRuntime = fakeRuntime(), +): string { + const stateDirectory = join(projectRoot, ".state"); + mkdirSync(stateDirectory, { recursive: true, mode: 0o700 }); + chmodSync(stateDirectory, 0o700); + const path = join(stateDirectory, "runtime.json"); + writeFileSync(path, `${JSON.stringify(runtime, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(path, 0o600); + return path; +} diff --git a/tests/observer/codex-image.test.ts b/tests/observer/codex-image.test.ts index 7f587e9..ddda267 100644 --- a/tests/observer/codex-image.test.ts +++ b/tests/observer/codex-image.test.ts @@ -22,6 +22,7 @@ import { type CodexImageDependencies, } from "../../src/observer/codex-image.js"; import { loadEditorialConfig } from "../../src/observer/editorial-config.js"; +import { fakeRuntime, writeRuntimeManifest } from "../helpers/runtime.js"; import { attachIssue, buildRunReport } from "../../src/observer/run-report.js"; import type { ReportIssue } from "../../src/observer/report-sequence.js"; import { validEditorialPayload } from "../helpers/editorial.js"; @@ -221,7 +222,7 @@ describe("audited Codex-generated hero ingestion", () => { "/Users/example/.codex/generated_images", generatedImagesRoot, ); - const config = loadEditorialConfig(root); + const config = loadEditorialConfig(root, fakeRuntime()); const result = await generateCodexHero({ projectRoot: root, @@ -267,7 +268,7 @@ describe("audited Codex-generated hero ingestion", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), dependencies: deps, }); @@ -296,7 +297,7 @@ describe("audited Codex-generated hero ingestion", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), dependencies: dependencies(generatedImagesRoot, eventStream(path)), }), "artifact", @@ -316,7 +317,7 @@ describe("audited Codex-generated hero ingestion", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), dependencies: dependencies(generatedImagesRoot, eventStream(link)), }), "artifact", @@ -338,7 +339,7 @@ describe("audited Codex-generated hero ingestion", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), dependencies: dependencies(generatedImagesRoot, eventStream(path)), }), kind, @@ -353,7 +354,7 @@ describe("audited Codex-generated hero ingestion", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), dependencies: dependencies( generatedImagesRoot, readFileSync(UNEXPECTED_TOOL_FIXTURE, "utf8"), diff --git a/tests/observer/config.test.ts b/tests/observer/config.test.ts index 7b12fe5..3b33c9c 100644 --- a/tests/observer/config.test.ts +++ b/tests/observer/config.test.ts @@ -10,6 +10,7 @@ import { findObserverJob, loadObserverConfig, } from "../../src/observer/config.js"; +import { fakeRuntime, writeRuntimeManifest } from "../helpers/runtime.js"; const projectRoot = fileURLToPath(new URL("../..", import.meta.url)); const temporaryRoots: string[] = []; @@ -36,6 +37,7 @@ function writeRawConfig(contents: string): string { const root = mkdtempSync(join(tmpdir(), "wechatpilot-observer-config-")); mkdirSync(join(root, "config")); writeFileSync(join(root, "config", "observer-jobs.yaml"), contents, "utf8"); + writeRuntimeManifest(root); temporaryRoots.push(root); return root; } @@ -48,7 +50,7 @@ afterEach(() => { describe("loadObserverConfig", () => { it("loads the single audited observer job from the real configuration", () => { - const config = loadObserverConfig(projectRoot); + const config = loadObserverConfig(projectRoot, fakeRuntime()); expect(config.version).toBe(1); expect(config.jobs).toHaveLength(1); @@ -147,13 +149,13 @@ describe("loadObserverConfig", () => { describe("findObserverJob", () => { it("returns the audited job by id", () => { - const config = loadObserverConfig(projectRoot); + const config = loadObserverConfig(projectRoot, fakeRuntime()); expect(findObserverJob(config, "daily-tech-digest")).toBe(config.jobs[0]); }); it("throws a fixed message without echoing an unknown id", () => { - const config = loadObserverConfig(projectRoot); + const config = loadObserverConfig(projectRoot, fakeRuntime()); expect(() => findObserverJob(config, "private-user-input")).toThrow( /^Unknown observer job$/u, diff --git a/tests/observer/editorial-config.test.ts b/tests/observer/editorial-config.test.ts index e1165b0..173ae27 100644 --- a/tests/observer/editorial-config.test.ts +++ b/tests/observer/editorial-config.test.ts @@ -16,6 +16,7 @@ import { buildEditorialImageInvocation, loadEditorialConfig, } from "../../src/observer/editorial-config.js"; +import { fakeRuntime, writeRuntimeManifest } from "../helpers/runtime.js"; const projectRoot = fileURLToPath(new URL("../..", import.meta.url)); const temporaryRoots: string[] = []; @@ -34,6 +35,7 @@ function fixtureRoot(): string { join(configDirectory, name), ); } + writeRuntimeManifest(root); temporaryRoots.push(root); return root; } @@ -64,7 +66,7 @@ afterEach(() => { describe("editorial image configuration", () => { it("loads one exact image-only Codex profile", () => { - const config = loadEditorialConfig(projectRoot); + const config = loadEditorialConfig(projectRoot, fakeRuntime()); expect(config.mode).toBe("editorial"); expect(config.canvas).toEqual({ width: 1080, @@ -126,4 +128,13 @@ describe("editorial image configuration", () => { expect(message).toBe("Invalid editorial report configuration"); expect(message).not.toContain(secretLikeValue); }); + + + it("fails closed without an authoritative runtime", () => { + const root = fixtureRoot(); + rmSync(join(root, ".state"), { recursive: true, force: true }); + expect(() => loadEditorialConfig(root)).toThrowError( + "Invalid editorial report configuration", + ); + }); }); diff --git a/tests/observer/report-visual.test.ts b/tests/observer/report-visual.test.ts index b3ab775..e4a38bb 100644 --- a/tests/observer/report-visual.test.ts +++ b/tests/observer/report-visual.test.ts @@ -17,6 +17,7 @@ import { type GeneratedHeroArtifact, } from "../../src/observer/codex-image.js"; import { loadEditorialConfig } from "../../src/observer/editorial-config.js"; +import { fakeRuntime, writeRuntimeManifest } from "../helpers/runtime.js"; import { inspectReportVisuals, prepareReportVisual, @@ -136,7 +137,7 @@ describe("private report visual lifecycle", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: generateTwiceThenSucceed, sleep: async (milliseconds) => { delays.push(milliseconds); @@ -174,7 +175,7 @@ describe("private report visual lifecycle", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: failIfCalled, sleep: async () => undefined, }); @@ -193,7 +194,7 @@ describe("private report visual lifecycle", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: aborting, sleep: async () => undefined, }), @@ -208,7 +209,7 @@ describe("private report visual lifecycle", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: async () => artifact, sleep: async () => undefined, }); @@ -225,7 +226,7 @@ describe("private report visual lifecycle", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: failing, sleep: async () => undefined, }); @@ -246,7 +247,7 @@ describe("private report visual lifecycle", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: async () => { throw new Error("fallback visual must be reused"); }, @@ -267,7 +268,7 @@ describe("private report visual lifecycle", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: async () => { calls += 1; if (calls < 3) { @@ -285,7 +286,7 @@ describe("private report visual lifecycle", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: mustNotRun, sleep: async () => undefined, }); @@ -314,7 +315,7 @@ describe("visual state inspection", () => { projectRoot: root, report: report(root), issue: ISSUE, - config: loadEditorialConfig(root), + config: loadEditorialConfig(root, fakeRuntime()), generate: async () => { throw new CodexImageError("failed"); }, From c29ca08042a4733dc44e7c4ad0dd7ca0c1348825 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:01:27 +0800 Subject: [PATCH 14/14] fix: initialize runtime manifest in CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2039f90..0251a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: mkdir -p "$test_runtime_dir" cp scripts/ci-fake-codex.mjs "$test_runtime_dir/codex" chmod 755 "$test_runtime_dir/codex" + export PATH="$test_runtime_dir:$PATH" + npm run wechatpilot -- init echo "$test_runtime_dir" >> "$GITHUB_PATH" - name: Governance and portable-path checks