From 718e11be7006cf38aa7b5f9be46bc6cb97c2fafd Mon Sep 17 00:00:00 2001 From: Noah-Bytes Date: Tue, 21 Jul 2026 23:38:17 +0800 Subject: [PATCH 1/2] feat: owner sync, config-event persistence, version-check notify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port three adapter logics from zylos-openmax (reference), adapted to codex-openmax's TS thin-adapter over @openmaxai/openmax-agent-sdk: - runtime-config: mutable config provider + atomic persist (single on-disk writer) - config-events: onConfigEvent switch persists agent.config.* (dm/group policy, allowlists, owner_changed) — previously silently dropped - owner-sync: real syncSelf hydrates self display_name + owner_member_id from cws-core (pull-based, never clears a locally-set owner); 5-min periodic re-sync; onOwnerBind/onOwnerNameHint wired - version-check (opt-in, default off): npm-registry latest vs local, DM owner on a newer release; never self-upgrades Tests: +27 (173 total). build + typecheck + vitest all green. Co-Authored-By: Claude Opus 4.8 --- src/cli.ts | 59 ++++++++++- src/config-events.ts | 196 +++++++++++++++++++++++++++++++++++++ src/config.ts | 27 ++++- src/owner-sync.ts | 91 +++++++++++++++++ src/runtime-config.ts | 119 ++++++++++++++++++++++ src/scheduler.ts | 20 ++++ src/version-check.ts | 145 +++++++++++++++++++++++++++ test/config-events.test.ts | 160 ++++++++++++++++++++++++++++++ test/owner-sync.test.ts | 101 +++++++++++++++++++ test/version-check.test.ts | 108 ++++++++++++++++++++ 10 files changed, 1019 insertions(+), 7 deletions(-) create mode 100644 src/config-events.ts create mode 100644 src/owner-sync.ts create mode 100644 src/runtime-config.ts create mode 100644 src/scheduler.ts create mode 100644 src/version-check.ts create mode 100644 test/config-events.test.ts create mode 100644 test/owner-sync.test.ts create mode 100644 test/version-check.test.ts diff --git a/src/cli.ts b/src/cli.ts index b9c48c6..088ed36 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,16 @@ import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import { buildConfig, writeConfigFile, type FetchLike, type OnboardInput } from "./onboarding.js"; -import { loadConfig, type AppConfig } from "./config.js"; +import { loadConfig, resolveConfigPath, type AppConfig } from "./config.js"; +import { buildConfigProvider } from "./runtime-config.js"; +import { makeSyncSelf } from "./owner-sync.js"; +import { handleConfigEvent } from "./config-events.js"; +import { everyMs } from "./scheduler.js"; +import { resolveVersionCheckSchedule, makeVersionCheck } from "./version-check.js"; + +// Owner re-sync cadence for long-lived connections (the SDK only hydrates self at +// connect time). 5 min mirrors the zylos OWNER_SYNC_INTERVAL_MS. +const OWNER_SYNC_INTERVAL_MS = 5 * 60 * 1000; function usage(): never { console.error(`usage: @@ -83,16 +92,20 @@ async function cmdStart(): Promise { const sdk = (await import("@openmaxai/openmax-agent-sdk")) as Record; const { createSdkCwsBridge } = await import("./bridge/sdk-bridge.js"); const { main } = await import("./index.js"); - const { server, agent, cfAccess, orgs } = config; + const { server, agent, cfAccess } = config; const log = (...a: unknown[]) => console.log(new Date().toISOString(), ...a); const logger = { info: log, warn: log, error: log, debug: () => {}, log }; + // Runtime config provider: owns the mutable config state + the single on-disk writer. + // enabledOrgs() returns the SAME org objects handed to the SDK as orgConfigs (captured by + // reference), so owner/self/access write-backs are visible to the SDK without a restart. + const provider = buildConfigProvider(config, resolveConfigPath(), logger); // The SDK's cfAccessHeaders() reads `cfg.cf_access.{client_id,client_secret}` (WRAPPED), // so the bare block must be wrapped as { cf_access: ... }; env COCO_CF_ACCESS_* still wins // inside the SDK. Omitted entirely when no cf_access is configured. const cfAccessWrapped = cfAccess ? { cf_access: cfAccess } : undefined; // enabled:false opts an org out (mirrors claude-openmax / the openmax component). - const activeOrgs = orgs.filter((o) => o.enabled !== false); - const defaultOrgId = () => activeOrgs[0]?.org_id ?? orgs[0].org_id; + const activeOrgs = provider.enabledOrgs(); + const defaultOrgId = () => activeOrgs[0]?.org_id ?? config.orgs[0].org_id; const tokenManager = new sdk.TokenManager({ apiKey: agent.apiKey, coreUrl: server.bffUrl, @@ -112,6 +125,9 @@ async function cmdStart(): Promise { resolveDefaultOrgId: defaultOrgId, logger, }); + // syncSelf hydrates self.display_name + owner from cws-core (pull-based). Serves the + // SDK's connect-time self-name barrier AND the periodic owner re-sync below. + const syncSelf = makeSyncSelf(http, provider, logger); const bridge = createSdkCwsBridge( (deliver) => new sdk.CwsAgentBridge({ @@ -125,14 +141,47 @@ async function cmdStart(): Promise { }, orgConfigs: activeOrgs, providers: { logger, inbound: { deliver } }, - callbacks: { syncSelf: async () => ({ nameReady: true }) }, + callbacks: { + syncSelf, + // The SDK reads this at the hydration barrier; org_id-keyed enabled-org view. + loadConfig: () => ({ orgs: Object.fromEntries(provider.enabledOrgs().map((o) => [o.org_id, o])) }), + // agent.config.* → apply to access + persist. Let it throw so the SDK retries. + onConfigEvent: async (orgConfig: any, evt: any) => + handleConfigEvent(provider, orgConfig, { event: evt.event, data: evt.data }, { log: logger, resyncOwner: syncSelf }), + // Owner auto-bind fallback (core had none) / owner name hint → persist. + onOwnerBind: (orgId: string, memberId: string, displayName: string) => provider.setOwner(orgId, memberId, displayName || ""), + onOwnerNameHint: (orgId: string, name: string) => { + const org = provider.getOrgByOrgId(orgId); + if (org) { + org.owner = { ...(org.owner || { member_id: "" }), name }; + provider.persist(); + } + }, + }, reporters: { metrics: false }, }), ); const handle = await main(bridge); log(`[codex-openmax] online — adapter on :${handle.port}, orgs=${activeOrgs.map((o) => o.org_id).join(",")}`); + + // Periodic timers (disposed in stop()). All background work swallows rejections (everyMs). + const disposers: Array<() => void> = []; + // (1) Owner re-sync — covers long-lived connections the connect-time barrier can't. + disposers.push(everyMs(OWNER_SYNC_INTERVAL_MS, async () => { + for (const org of provider.enabledOrgs()) await syncSelf(org); + })); + // (2) Version check (option 甲) — opt-in; DM the owner on a newer npm release, never self-upgrade. + const vcSchedule = resolveVersionCheckSchedule(config.versionCheck); + if (vcSchedule.enabled) { + const comm = sdk.createCommService(http, provider); + const check = makeVersionCheck({ provider, comm, log: logger }); + disposers.push(everyMs(vcSchedule.intervalMs, check)); + log(`[codex-openmax] version-check enabled (every ${vcSchedule.intervalHours}h)`); + } + const stop = async () => { log("[codex-openmax] stopping…"); + for (const dispose of disposers) dispose(); await handle.stop(); process.exit(0); }; diff --git a/src/config-events.ts b/src/config-events.ts new file mode 100644 index 0000000..1bd40dd --- /dev/null +++ b/src/config-events.ts @@ -0,0 +1,196 @@ +// agent.config.* event handling — the DM/group access-policy switch. +// +// Ported from zylos-openmax comm-bridge.js handleConfigUpdate (:1110-1282). The SDK +// classifies each agent.config.* frame, does the "not for us" target check, and hands +// us { event, data, frame } via callbacks.onConfigEvent — it does NOT persist. This +// module applies the change to the org's access block (persisted via the provider) AND +// mutates the passed orgConfig in place so the SDK's live copy takes effect without a +// restart. +// +// The mutation is written as an idempotent ASSIGNMENT (never an in-place push): the +// provider's org record and the SDK's orgConfig are the same object in the normal wiring, +// so applying the change to both must be safe under double application. +// +// owner_changed is special: ownership is NEVER set from a pushed frame (a forged frame +// must not hand the bot to an attacker) — it only TRIGGERS a pull-based owner re-sync. +import type { ConfigProvider, Logger } from "./runtime-config.js"; +import type { OrgConfig, OrgAccess } from "./config.js"; + +const VALID_DM_POLICIES = new Set(["open", "allowlist", "owner"]); +const VALID_GROUP_SCOPES = new Set(["open", "allowlist", "disabled"]); +const VALID_GROUP_MODES = new Set(["smart", "mention", "silent"]); + +export interface ConfigEvent { + event: string; + data: Record; +} + +export interface ConfigEventDeps { + log: Logger; + /** Pull-based owner re-sync for owner_changed (typically owner-sync's syncSelf). */ + resyncOwner?: (orgConfig: OrgConfig) => Promise; +} + +/** + * Handle one agent.config.* event. Throws on a genuine (unexpected) error so the SDK + * leaves the event unconsumed and retries on replay; a validation failure (bad policy, + * missing field) is logged and returns without mutating. + */ +export async function handleConfigEvent(provider: ConfigProvider, orgConfig: OrgConfig, evt: ConfigEvent, deps: ConfigEventDeps): Promise { + const { event, data } = evt; + const { log } = deps; + const orgId = orgConfig.org_id; + if (!data || typeof data !== "object") return; + + // Apply a mutation to BOTH the provider's record (persisted) and the SDK's live copy. + // `apply` MUST be idempotent (assignment-only) — see file header. + const applyBoth = (apply: (org: OrgConfig) => void): void => { + provider.updateConfig((cfg) => { + const o = cfg.orgs[orgId]; + if (o) apply(o); + }); + apply(orgConfig); + }; + const ensureAccess = (o: OrgConfig): OrgAccess => (o.access = o.access || {}); + + switch (event) { + case "agent.config.dm_policy_changed": { + const policy = data.policy as string; + if (!VALID_DM_POLICIES.has(policy)) { + log.warn?.(`[${orgId}] dm_policy_changed: invalid policy "${policy}"`); + return; + } + applyBoth((o) => { + ensureAccess(o).dmPolicy = policy; + }); + log.info?.(`[${orgId}] config updated: dmPolicy → ${policy} (by ${data.changed_by || "?"})`); + return; + } + + case "agent.config.dm_allowlist_changed": { + const action = data.action as string; + const memberIds = data.member_ids as string[]; + if (!Array.isArray(memberIds) || !memberIds.length) { + log.warn?.(`[${orgId}] dm_allowlist_changed: missing or empty member_ids`); + return; + } + if (!["add", "remove", "set"].includes(action)) { + log.warn?.(`[${orgId}] dm_allowlist_changed: unknown action "${action}"`); + return; + } + applyBoth((o) => { + const access = ensureAccess(o); + const current = access.dmAllowFrom || []; + if (action === "add") access.dmAllowFrom = [...new Set([...current, ...memberIds])]; + else if (action === "remove") { + const remove = new Set(memberIds.map(String)); + access.dmAllowFrom = current.filter((id) => !remove.has(String(id))); + } else access.dmAllowFrom = [...memberIds]; + }); + log.info?.(`[${orgId}] config updated: dmAllowFrom ${action} ${memberIds.length} member(s) (by ${data.changed_by || "?"})`); + return; + } + + case "agent.config.group_mode_changed": { + const mode = data.mode as string; + const convId = data.conversation_id as string; + if (!VALID_GROUP_MODES.has(mode)) { + log.warn?.(`[${orgId}] group_mode_changed: invalid mode "${mode}"`); + return; + } + if (!convId) { + log.warn?.(`[${orgId}] group_mode_changed: missing conversation_id`); + return; + } + applyBoth((o) => { + const access = ensureAccess(o); + const groups = (access.groups = access.groups || {}); + // 'silent' means "don't participate" → drop the entry entirely. + if (mode === "silent") delete groups[convId]; + else { + groups[convId] = groups[convId] || { allowFrom: ["*"] }; + groups[convId].mode = mode; + } + }); + log.info?.(`[${orgId}] config updated: group ${convId} mode → ${mode} (by ${data.changed_by || "?"})`); + return; + } + + case "agent.config.group_allowfrom_changed": { + const allowFrom = data.allow_from as string[]; + const convId = data.conversation_id as string; + if (!convId) { + log.warn?.(`[${orgId}] group_allowfrom_changed: missing conversation_id`); + return; + } + if (!Array.isArray(allowFrom)) { + log.warn?.(`[${orgId}] group_allowfrom_changed: allow_from is not an array`); + return; + } + applyBoth((o) => { + const access = ensureAccess(o); + const groups = (access.groups = access.groups || {}); + if (!groups[convId]) groups[convId] = { mode: "mention", allowFrom: [...allowFrom] }; + else groups[convId].allowFrom = [...allowFrom]; + }); + log.info?.(`[${orgId}] config updated: group ${convId} allowFrom → ${JSON.stringify(allowFrom)} (by ${data.changed_by || "?"})`); + return; + } + + case "agent.config.group_scope_changed": { + const scope = data.scope as string; + if (!VALID_GROUP_SCOPES.has(scope)) { + log.warn?.(`[${orgId}] group_scope_changed: invalid scope "${scope}"`); + return; + } + applyBoth((o) => { + ensureAccess(o).groupPolicy = scope; + }); + log.info?.(`[${orgId}] config updated: groupPolicy → ${scope} (by ${data.changed_by || "?"})`); + return; + } + + case "agent.config.group_allowlist_changed": { + const action = data.action as string; + const convIds = data.conversation_ids as string[]; + if (!Array.isArray(convIds)) { + log.warn?.(`[${orgId}] group_allowlist_changed: conversation_ids is not an array`); + return; + } + if (!["add", "remove", "set"].includes(action)) { + log.warn?.(`[${orgId}] group_allowlist_changed: unknown action "${action}"`); + return; + } + applyBoth((o) => { + const access = ensureAccess(o); + const groups = (access.groups = access.groups || {}); + if (action === "add") { + for (const id of convIds) if (!groups[id]) groups[id] = { mode: "mention", allowFrom: ["*"] }; + } else if (action === "remove") { + for (const id of convIds) delete groups[id]; + } else { + // set: keep existing entries for the listed convs, drop the rest. + const old = groups; + const next: NonNullable = {}; + for (const id of convIds) next[id] = old[id] || { mode: "mention", allowFrom: ["*"] }; + access.groups = next; + } + }); + log.info?.(`[${orgId}] config updated: group_allowlist ${action} ${convIds.length} conversation(s) (by ${data.changed_by || "?"})`); + return; + } + + case "agent.config.owner_changed": { + const oldOwner = (data.old_owner_member_id as string) || ""; + const newOwner = (data.new_owner_member_id as string) || ""; + log.info?.(`[${orgId}] owner_changed event: ${oldOwner || "(none)"} → ${newOwner || "(none)"} by=${data.changed_by || "?"} — re-syncing from core`); + // NEVER trust the pushed frame to set ownership — pull the authoritative record. + if (deps.resyncOwner) await deps.resyncOwner(orgConfig); + return; + } + + default: + log.warn?.(`[${orgId}] unknown config event: ${event}`); + return; + } +} diff --git a/src/config.ts b/src/config.ts index 92b8ca1..d349128 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,7 +38,7 @@ export interface OrgAccess { dmPolicy?: string; dmAllowFrom?: string[]; groupPolicy?: string; - groups?: Record; + groups?: Record; } /** One org, in the bridge / openmax-mirrored shape handed straight to the SDK @@ -61,6 +61,10 @@ export interface AppConfig { // codex-openmax runtime-specific (no analog in claude-openmax / openmax): codex: { bin: string; cwd: string }; bridge: { localHttpPort: number }; + // Optional periodic "a newer release is on npm" check (option 甲 — notify the + // owner, never self-upgrade). On disk under `version_check`. Disabled unless + // explicitly enabled; see version-check.ts. + versionCheck?: { enabled?: boolean; intervalHours?: number }; } const REQUIRED = [ @@ -93,12 +97,21 @@ function parseOrgs(raw: Record): OrgConfig[] { return orgs; } +/** + * Resolve the config file path exactly as loadConfig does. Exported so the runtime + * config provider (runtime-config.ts) writes back to the SAME file loadConfig read + * from — the two must agree or a persist would target the wrong path. + */ +export function resolveConfigPath(path?: string): string { + return path ?? process.env.CODEX_OPENMAX_CONFIG ?? "config.json"; +} + /** * Load config from a JSON file (path arg, else $CODEX_OPENMAX_CONFIG, else ./config.json), * then apply env overrides, then validate required fields. Throws on missing/invalid. */ export function loadConfig(path?: string): AppConfig { - const file = path ?? process.env.CODEX_OPENMAX_CONFIG ?? "config.json"; + const file = resolveConfigPath(path); let raw: Record = {}; try { raw = JSON.parse(readFileSync(file, "utf8")); @@ -133,6 +146,16 @@ export function loadConfig(path?: string): AppConfig { bridge: { localHttpPort: Number(process.env.BRIDGE_HTTP_PORT ?? raw.bridge?.localHttpPort ?? DEFAULT_LOCAL_HTTP_PORT), }, + // Optional; on disk as `version_check: { enabled, interval_hours }`. Absent → undefined + // (version-check stays disabled). resolveVersionCheckSchedule owns the default interval. + ...(raw.version_check && typeof raw.version_check === "object" + ? { + versionCheck: { + ...(raw.version_check.enabled !== undefined ? { enabled: !!raw.version_check.enabled } : {}), + ...(raw.version_check.interval_hours !== undefined ? { intervalHours: Number(raw.version_check.interval_hours) } : {}), + }, + } + : {}), }; const missing = REQUIRED.filter(([, get]) => !get(cfg)).map(([name]) => name); diff --git a/src/owner-sync.ts b/src/owner-sync.ts new file mode 100644 index 0000000..1b82801 --- /dev/null +++ b/src/owner-sync.ts @@ -0,0 +1,91 @@ +// Self-name hydration + owner sync (pull-based). +// +// Ported from zylos-openmax comm-bridge.js syncOwnerFromCore. The bot's local +// `orgs..owner` block is a CACHE; cws-core holds the authoritative owner +// (our own member record's `owner_member_id`). On the connect-time hydration +// barrier (SDK callbacks.syncSelf) AND on a periodic timer / owner_changed event, +// we pull our own member record and reconcile. +// +// Two things happen off the ONE self-member read (no extra round-trip, matching zylos): +// 1. self.display_name ← core (so inbound @-mention detection matches the exact +// name cws-fe shows, not a hand-configured self.name that silently drifts). +// 2. owner ← core.owner_member_id, resolving the owner's display_name for the cache. +// +// INVARIANTS: +// - Pull-based ONLY: a pushed WS frame never sets ownership (a forged frame must not +// be able to hand the bot to an attacker). owner_changed just TRIGGERS this pull. +// - NEVER clear a locally-set owner when core reports none — that keeps the first-DM +// auto-bind fallback working (zylos comm-bridge.js:1811-1814). +// - Fail-open: never throws. The SDK's hydration barrier retries; a failed sync must +// report nameReady:false (with a reason), never be mistaken for success. +import type { ConfigProvider, Logger } from "./runtime-config.js"; +import type { OrgConfig } from "./config.js"; + +/** The subset of the SDK's CwsHttpClient this module needs. */ +export interface HttpForOrg { + getForOrg(orgId: string, path: string, query?: unknown): Promise; + apiPath(p: string): string; +} + +/** Result the SDK self-name hydration barrier expects (identity/self-name-hydration.js): + * `nameReady:true` ONLY once the authoritative self-member record was actually read. */ +export type SyncSelfResult = { nameReady: true; displayName?: string } | { nameReady: false; reason: string }; + +/** A member record as returned by GET /members/{id} (only the fields we read). */ +interface MemberRecord { + display_name?: string; + username?: string; + owner_member_id?: string; +} + +/** + * Build the syncSelf function bound to `http` + `provider`. The returned function serves + * BOTH as the SDK `callbacks.syncSelf` (connect-time barrier) and as the periodic / + * owner_changed owner re-sync (its result is ignored by those callers). + */ +export function makeSyncSelf(http: HttpForOrg, provider: ConfigProvider, log: Logger): (orgConfig: OrgConfig) => Promise { + return async function syncSelf(orgConfig: OrgConfig): Promise { + const orgId = orgConfig.org_id; + const selfMemberId = orgConfig.self?.member_id; + if (!selfMemberId) { + // member_id is written back by the token exchange; not there yet → retry next round. + return { nameReady: false, reason: "self.member_id not available yet (token exchange write-back pending)" }; + } + + let member: MemberRecord; + try { + member = (await http.getForOrg(orgId, http.apiPath(`/members/${selfMemberId}`))) as MemberRecord; + } catch (err) { + log.warn?.(`[${orgId}] owner-sync: fetch self member failed: ${err instanceof Error ? err.message : String(err)} — keeping local owner`); + return { nameReady: false, reason: `fetch self member failed: ${err instanceof Error ? err.message : String(err)}` }; + } + + // (1) self display_name ← core (cosmetic-but-important: @-mention matching). + const coreDisplayName = member?.display_name || ""; + if (coreDisplayName && coreDisplayName !== orgConfig.self?.display_name) { + provider.setSelfDisplayName(orgId, coreDisplayName); + orgConfig.self = { ...(orgConfig.self || { member_id: selfMemberId }), display_name: coreDisplayName }; + log.info?.(`[${orgId}] self display_name synced from core: ${coreDisplayName}`); + } + + // (2) owner ← core. Core has no owner → LEAVE the local binding as-is (invariant). + const coreOwnerId = member?.owner_member_id || ""; + if (!coreOwnerId) return { nameReady: true, displayName: coreDisplayName || undefined }; + + const localOwnerId = orgConfig.owner?.member_id || ""; + if (coreOwnerId === localOwnerId) return { nameReady: true, displayName: coreDisplayName || undefined }; + + let ownerName = ""; + try { + const ownerMember = (await http.getForOrg(orgId, http.apiPath(`/members/${coreOwnerId}`))) as MemberRecord; + ownerName = ownerMember?.display_name || ownerMember?.username || ""; + } catch { + // owner display_name is cosmetic — a fetch failure must not block the owner bind. + } + provider.setOwner(orgId, coreOwnerId, ownerName); + orgConfig.owner = { member_id: coreOwnerId, name: ownerName }; + log.info?.(`[${orgId}] owner synced from core: ${localOwnerId || "(none)"} → ${coreOwnerId}${ownerName ? ` (${ownerName})` : ""}`); + + return { nameReady: true, displayName: coreDisplayName || undefined }; + }; +} diff --git a/src/runtime-config.ts b/src/runtime-config.ts new file mode 100644 index 0000000..83f2e88 --- /dev/null +++ b/src/runtime-config.ts @@ -0,0 +1,119 @@ +// Runtime config provider + persistence. +// +// The SDK's CwsAgentBridge NEVER writes config.json — it classifies agent.config.* +// events and hands them to callbacks, and it captures each orgConfig BY REFERENCE. +// This module owns the mutable in-memory config state and the single on-disk writer, +// mirroring the claude-openmax sibling's buildRuntime/persist/configProvider. +// +// It exposes the config-provider shape the SDK's CommService expects +// (enabledOrgs / getOrgByOrgId / updateConfig / setOwner), plus the two write-backs +// the adapter drives directly (setSelfDisplayName, persist). Because `orgs` is kept +// as the SAME array of objects handed to the SDK as `orgConfigs`, a mutation here is +// visible to the SDK's live copies without a restart. +import * as fs from "node:fs"; +import { writeConfigFile } from "./onboarding.js"; +import type { AppConfig, OrgConfig } from "./config.js"; + +/** Logger shape used across the adapter (cli.ts builds one from console). */ +export interface Logger { + info: (...a: unknown[]) => void; + warn: (...a: unknown[]) => void; + error?: (...a: unknown[]) => void; +} + +/** The config-provider seam the SDK CommService (and our own owner-sync / config-events) + * consume. `updateConfig` hands `fn` an org_id-keyed VIEW; mutations are synced back to + * the array and persisted. */ +export interface ConfigProvider { + enabledOrgs(): OrgConfig[]; + getOrgByOrgId(id: string): OrgConfig | undefined; + updateConfig(fn: (cfg: { orgs: Record }) => void): { orgs: Record }; + setOwner(orgId: string, memberId: string, name: string): void; + setSelfDisplayName(orgId: string, name: string): void; + persist(): void; +} + +/** Serialize one internal org record back to its on-disk (bridge / openmax) form. + * Mirrors onboarding.ts assemble + config.ts parseOrgs so it round-trips through loadConfig. */ +function serializeOrg(o: OrgConfig): Record { + return { + ...(o.enabled !== undefined ? { enabled: o.enabled } : {}), + org_id: o.org_id, + org_name: o.org_name || "", + owner: o.owner || { member_id: "", name: "" }, + self: o.self || { member_id: "", name: "", display_name: "" }, + access: o.access || {}, + }; +} + +/** Assemble the full org_id-keyed, snake_case on-disk config from the camelCase AppConfig. + * The whole file is rewritten on every persist, so EVERY known block must be reproduced here + * (dropping one would erase it from disk on the next write-back). */ +function assembleOnDisk(cfg: AppConfig): Record { + return { + ...(cfg.enabled !== undefined ? { enabled: cfg.enabled } : {}), + server: { bff_url: cfg.server.bffUrl, ws_url: cfg.server.wsUrl, frontend_base_path: cfg.server.frontendBasePath }, + agent: { identity_id: cfg.agent.identityId, api_key: cfg.agent.apiKey, device_id: cfg.agent.deviceId, app_version: cfg.agent.appVersion }, + ...(cfg.cfAccess ? { cf_access: cfg.cfAccess } : {}), + orgs: Object.fromEntries(cfg.orgs.map((o) => [o.org_id, serializeOrg(o)])), + codex: { bin: cfg.codex.bin, cwd: cfg.codex.cwd }, + bridge: { localHttpPort: cfg.bridge.localHttpPort }, + ...(cfg.versionCheck + ? { + version_check: { + ...(cfg.versionCheck.enabled !== undefined ? { enabled: cfg.versionCheck.enabled } : {}), + ...(cfg.versionCheck.intervalHours !== undefined ? { interval_hours: cfg.versionCheck.intervalHours } : {}), + }, + } + : {}), + }; +} + +/** + * Build the runtime config provider. `cfg` is mutated in place (its `orgs` array IS the + * array of objects handed to the SDK as `orgConfigs`), and `persist()` writes the whole + * config back to `filePath` atomically at 0o600 via the shared writeConfigFile. + */ +export function buildConfigProvider(cfg: AppConfig, filePath: string, logger: Logger): ConfigProvider { + const orgByOrgId = (id: string): OrgConfig | undefined => cfg.orgs.find((o) => o.org_id === id); + // `enabled: false` opts an org out of the SDK-facing view, but persist() still writes ALL + // orgs so a disabled one is never dropped from disk. + const enabledOrgs = (): OrgConfig[] => cfg.orgs.filter((o) => o.enabled !== false); + + const persist = (): void => { + try { + // 0o600 + atomic tmp+rename — the config holds secrets (agent.api_key, cf_access). + writeConfigFile(fs, filePath, assembleOnDisk(cfg)); + } catch (e) { + logger.warn?.(`config persist failed: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + const updateConfig = (fn: (view: { orgs: Record }) => void): { orgs: Record } => { + // The view maps to the SAME org objects (by reference); mutating a nested field on + // view.orgs[id] mutates the SDK's live copy too. fn must not REPLACE an org object. + const view = { orgs: Object.fromEntries(cfg.orgs.map((o) => [o.org_id, o])) }; + fn(view); + cfg.orgs = Object.values(view.orgs); + persist(); + return view; + }; + + const setOwner = (orgId: string, memberId: string, name: string): void => { + const org = orgByOrgId(orgId); + if (org) { + org.owner = { member_id: memberId, name: name || "" }; + persist(); + } + }; + + const setSelfDisplayName = (orgId: string, name: string): void => { + const org = orgByOrgId(orgId); + if (org) { + org.self = { ...(org.self || { member_id: "" }), display_name: name }; + persist(); + } + }; + + return { enabledOrgs, getOrgByOrgId: orgByOrgId, updateConfig, setOwner, setSelfDisplayName, persist }; +} diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..57ef7a1 --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,20 @@ +// Minimal recurring-timer primitive. The adapter has no scheduler; owner-resync and +// version-check both need "run fn every N ms" with a disposer that stop() can call. +// +// The callback's rejections are swallowed (a periodic background task must never surface +// an unhandledRejection that could crash the process). The timer is unref()'d so it never +// keeps the event loop alive on its own. + +/** Run `fn` every `intervalMs` (first run after one interval, not immediately). Returns a + * disposer that clears the interval — MUST be called from the stop()/teardown path. */ +export function everyMs(intervalMs: number, fn: () => void | Promise): () => void { + const timer = setInterval(() => { + try { + void Promise.resolve(fn()).catch(() => {}); + } catch { + // synchronous throw from fn — swallow, same as a rejected promise. + } + }, intervalMs); + timer.unref?.(); + return () => clearInterval(timer); +} diff --git a/src/version-check.ts b/src/version-check.ts new file mode 100644 index 0000000..16692f9 --- /dev/null +++ b/src/version-check.ts @@ -0,0 +1,145 @@ +// Version check — option 甲: periodically ask npm whether a newer @openmaxai/codex-openmax +// is published and, if so, DM the org owner. It NEVER self-upgrades (unlike the zylos +// auto-upgrade PM2 machinery it is trimmed down from) — the owner runs the upgrade. +// +// Ported from zylos-openmax auto-upgrade.js: resolveAutoUpgradeSchedule (disabled by +// default) and compareSemver — fixed to sort prerelease tags correctly (the naive numeric +// split mis-sorts `0.1.0-alpha.3`). +import { readFileSync } from "node:fs"; +import type { ConfigProvider, Logger } from "./runtime-config.js"; + +const REGISTRY_URL = "https://registry.npmjs.org/@openmaxai/codex-openmax/latest"; +const DEFAULT_INTERVAL_HOURS = 24; +const FETCH_TIMEOUT_MS = 5000; + +/** Read this package's own version. From dist/version-check.js OR src/version-check.ts the + * package.json is one directory up (dist/ and src/ both sit directly under the repo root). */ +export function readLocalVersion(): string { + try { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string }; + return pkg.version || "0.0.0"; + } catch { + return "0.0.0"; + } +} + +/** GET the latest published version from the npm registry. Throws on network/HTTP failure. */ +export async function fetchLatestVersion(): Promise { + const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`npm registry ${res.status}: ${res.statusText}`); + const data = (await res.json()) as { version?: string }; + return (data.version || "").replace(/^v/, ""); +} + +/** Compare a prerelease identifier list (semver §11): numeric < non-numeric, fewer fields + * has lower precedence. `alpha.3` < `alpha.4`; `alpha` < `alpha.1`. */ +function comparePre(a: string, b: string): number { + const as = a.split("."); + const bs = b.split("."); + const len = Math.max(as.length, bs.length); + for (let i = 0; i < len; i++) { + if (as[i] === undefined) return -1; + if (bs[i] === undefined) return 1; + const an = /^\d+$/.test(as[i]); + const bn = /^\d+$/.test(bs[i]); + if (an && bn) { + const d = Number(as[i]) - Number(bs[i]); + if (d !== 0) return d < 0 ? -1 : 1; + } else if (an) return -1; + else if (bn) return 1; + else if (as[i] !== bs[i]) return as[i] < bs[i] ? -1 : 1; + } + return 0; +} + +/** semver compare, prerelease-aware. Returns -1 | 0 | 1. A release outranks a prerelease of + * the same core (`0.1.0` > `0.1.0-alpha.3`). */ +export function compareSemver(a: string, b: string): number { + const parse = (v: string): { nums: number[]; pre: string | null } => { + const [core, ...preParts] = v.replace(/^v/, "").split("-"); + return { nums: core.split(".").map((n) => Number(n) || 0), pre: preParts.length ? preParts.join("-") : null }; + }; + const pa = parse(a); + const pb = parse(b); + for (let i = 0; i < 3; i++) { + const x = pa.nums[i] || 0; + const y = pb.nums[i] || 0; + if (x < y) return -1; + if (x > y) return 1; + } + if (pa.pre === null && pb.pre === null) return 0; + if (pa.pre === null) return 1; // release > prerelease + if (pb.pre === null) return -1; + return comparePre(pa.pre, pb.pre); +} + +export type VersionCheckSchedule = { enabled: false } | { enabled: true; intervalHours: number; intervalMs: number }; + +/** Resolve the check schedule from config.versionCheck. DISABLED unless enabled === true. */ +export function resolveVersionCheckSchedule(settings?: { enabled?: boolean; intervalHours?: number }): VersionCheckSchedule { + if (settings?.enabled !== true) return { enabled: false }; + const intervalHours = Number(settings.intervalHours) || DEFAULT_INTERVAL_HOURS; + return { enabled: true, intervalHours, intervalMs: intervalHours * 3600_000 }; +} + +/** Minimal CommService surface used to DM the owner (SDK createCommService(http, provider)). */ +export interface CommForNotify { + createDm(params: { peerMemberId: string }): Promise<{ conversation?: { id?: string } } | undefined>; + send(params: { conversationId: string; content: string }): Promise; +} + +export interface VersionCheckDeps { + provider: ConfigProvider; + comm: CommForNotify; + log: Logger; + /** Injectable for tests. */ + fetchLatest?: () => Promise; + localVersion?: string; +} + +/** Build the periodic check function. On a newer published version it DMs each enabled org's + * owner AT MOST ONCE per (org, latest-version) — the last-notified version is tracked in + * memory. Same-or-older → no notification. Never throws. */ +export function makeVersionCheck(deps: VersionCheckDeps): () => Promise { + const { provider, comm, log } = deps; + const fetchLatest = deps.fetchLatest || fetchLatestVersion; + const localVersion = deps.localVersion ?? readLocalVersion(); + // orgId → last latest-version we already notified about (dedupe repeated cycles). + const notified = new Map(); + + return async function check(): Promise { + let latest: string; + try { + latest = await fetchLatest(); + } catch (e) { + log.warn?.(`[version-check] fetch latest failed: ${e instanceof Error ? e.message : String(e)}`); + return; + } + if (!latest) return; + if (compareSemver(localVersion, latest) >= 0) { + log.info?.(`[version-check] up to date (local ${localVersion}, latest ${latest})`); + return; + } + + const text = + `codex-openmax ${localVersion}→${latest} 有新版,请运行 \`npm i -g @openmaxai/codex-openmax\` 并重启。`; + for (const org of provider.enabledOrgs()) { + const ownerId = org.owner?.member_id; + if (!ownerId) continue; + if (notified.get(org.org_id) === latest) continue; + try { + const dm = await comm.createDm({ peerMemberId: ownerId }); + const convId = dm?.conversation?.id; + if (!convId) { + log.warn?.(`[version-check] [${org.org_id}] could not resolve owner DM conversation`); + continue; + } + await comm.send({ conversationId: convId, content: text }); + notified.set(org.org_id, latest); + log.info?.(`[version-check] [${org.org_id}] owner notified of ${localVersion}→${latest}`); + } catch (e) { + log.warn?.(`[version-check] [${org.org_id}] notify failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + }; +} diff --git a/test/config-events.test.ts b/test/config-events.test.ts new file mode 100644 index 0000000..9ae428d --- /dev/null +++ b/test/config-events.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { writeFileSync, rmSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, type OrgConfig } from "../src/config.js"; +import { buildConfigProvider } from "../src/runtime-config.js"; +import { handleConfigEvent } from "../src/config-events.js"; +import { makeSyncSelf } from "../src/owner-sync.js"; + +const quietLog = { info() {}, warn() {}, error() {} }; + +const BASE = { + enabled: true, + server: { bff_url: "https://x", ws_url: "wss://x", frontend_base_path: "/workspace" }, + agent: { identity_id: "id_1", api_key: "cwsk_x", device_id: "dev_1", app_version: "codex-openmax/9.9.9" }, + orgs: { + org_1: { + enabled: true, + org_id: "org_1", + org_name: "Org One", + owner: { member_id: "", name: "" }, + self: { member_id: "m_self", name: "Codex", display_name: "Codex" }, + access: { dmPolicy: "owner", dmAllowFrom: [] as string[], groupPolicy: "allowlist", groups: {} as Record }, + }, + }, + codex: { bin: "codex", cwd: "/tmp" }, + bridge: { localHttpPort: 8787 }, +}; + +let currentPath = ""; +function setup(overrides?: Record) { + const dir = mkdtempSync(join(tmpdir(), "codex-cfgev-")); + const p = join(dir, "config.json"); + writeFileSync(p, JSON.stringify(overrides ? { ...BASE, ...overrides } : BASE)); + currentPath = p; + const cfg = loadConfig(p); + const provider = buildConfigProvider(cfg, p, quietLog); + const org = provider.enabledOrgs()[0]; + return { p, provider, org, reloadedOrg: (): OrgConfig => loadConfig(p).orgs[0] }; +} +afterEach(() => { + if (currentPath) rmSync(currentPath, { force: true }); + currentPath = ""; +}); + +describe("handleConfigEvent — DM 策略", () => { + it("当收到有效 dm_policy_changed 时,应更新 access.dmPolicy、落盘并同步到传入的 orgConfig", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.dm_policy_changed", data: { policy: "open" } }, { log: quietLog }); + expect(org.access.dmPolicy).toBe("open"); // 传入的 SDK 实时副本已更新 + expect(reloadedOrg().access.dmPolicy).toBe("open"); // 已落盘并可 round-trip + }); + + it("当 dm_policy_changed 携带非法 policy 时,应拒绝且不修改", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.dm_policy_changed", data: { policy: "bogus" } }, { log: quietLog }); + expect(org.access.dmPolicy).toBe("owner"); + expect(reloadedOrg().access.dmPolicy).toBe("owner"); + }); +}); + +describe("handleConfigEvent — DM 白名单 add/remove/set", () => { + it("当 action=add 时,应并集去重地追加成员并落盘", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.dm_allowlist_changed", data: { action: "add", member_ids: ["a", "b", "a"] } }, { log: quietLog }); + expect(org.access.dmAllowFrom).toEqual(["a", "b"]); + expect(reloadedOrg().access.dmAllowFrom).toEqual(["a", "b"]); + }); + + it("当 action=remove 时,应移除指定成员", async () => { + const { provider, org } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.dm_allowlist_changed", data: { action: "add", member_ids: ["a", "b", "c"] } }, { log: quietLog }); + await handleConfigEvent(provider, org, { event: "agent.config.dm_allowlist_changed", data: { action: "remove", member_ids: ["b"] } }, { log: quietLog }); + expect(org.access.dmAllowFrom).toEqual(["a", "c"]); + }); + + it("当 action=set 时,应整体替换白名单", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.dm_allowlist_changed", data: { action: "add", member_ids: ["x"] } }, { log: quietLog }); + await handleConfigEvent(provider, org, { event: "agent.config.dm_allowlist_changed", data: { action: "set", member_ids: ["p", "q"] } }, { log: quietLog }); + expect(reloadedOrg().access.dmAllowFrom).toEqual(["p", "q"]); + }); + + it("当 member_ids 为空时,应拒绝", async () => { + const { provider, org } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.dm_allowlist_changed", data: { action: "add", member_ids: [] } }, { log: quietLog }); + expect(org.access.dmAllowFrom).toEqual([]); + }); +}); + +describe("handleConfigEvent — 群策略", () => { + it("当 group_mode_changed=mention 时,应写入该会话的 mode", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.group_mode_changed", data: { conversation_id: "c1", mode: "mention" } }, { log: quietLog }); + expect(org.access.groups?.c1).toEqual({ allowFrom: ["*"], mode: "mention" }); + expect(reloadedOrg().access.groups?.c1?.mode).toBe("mention"); + }); + + it("当 group_mode_changed=silent 时,应删除该会话条目", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.group_mode_changed", data: { conversation_id: "c1", mode: "mention" } }, { log: quietLog }); + await handleConfigEvent(provider, org, { event: "agent.config.group_mode_changed", data: { conversation_id: "c1", mode: "silent" } }, { log: quietLog }); + expect(org.access.groups?.c1).toBeUndefined(); + expect(reloadedOrg().access.groups?.c1).toBeUndefined(); + }); + + it("当 group_mode_changed 携带非法 mode 时,应拒绝", async () => { + const { provider, org } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.group_mode_changed", data: { conversation_id: "c1", mode: "bogus" } }, { log: quietLog }); + expect(org.access.groups?.c1).toBeUndefined(); + }); + + it("当 group_allowfrom_changed 时,应写入该会话的 allowFrom", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.group_allowfrom_changed", data: { conversation_id: "c1", allow_from: ["u1", "u2"] } }, { log: quietLog }); + expect(org.access.groups?.c1?.allowFrom).toEqual(["u1", "u2"]); + expect(reloadedOrg().access.groups?.c1?.allowFrom).toEqual(["u1", "u2"]); + }); + + it("当 group_scope_changed 有效时应更新 groupPolicy,非法 scope 应拒绝", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.group_scope_changed", data: { scope: "disabled" } }, { log: quietLog }); + expect(reloadedOrg().access.groupPolicy).toBe("disabled"); + await handleConfigEvent(provider, org, { event: "agent.config.group_scope_changed", data: { scope: "bogus" } }, { log: quietLog }); + expect(reloadedOrg().access.groupPolicy).toBe("disabled"); + }); + + it("当 group_allowlist_changed add/remove/set 时,应相应增删/整体替换群条目", async () => { + const { provider, org, reloadedOrg } = setup(); + await handleConfigEvent(provider, org, { event: "agent.config.group_allowlist_changed", data: { action: "add", conversation_ids: ["c1", "c2"] } }, { log: quietLog }); + expect(Object.keys(org.access.groups ?? {}).sort()).toEqual(["c1", "c2"]); + await handleConfigEvent(provider, org, { event: "agent.config.group_allowlist_changed", data: { action: "remove", conversation_ids: ["c1"] } }, { log: quietLog }); + expect(Object.keys(org.access.groups ?? {})).toEqual(["c2"]); + await handleConfigEvent(provider, org, { event: "agent.config.group_allowlist_changed", data: { action: "set", conversation_ids: ["c3"] } }, { log: quietLog }); + expect(Object.keys(reloadedOrg().access.groups ?? {})).toEqual(["c3"]); + }); +}); + +describe("handleConfigEvent — owner_changed(拉取式、防伪造)", () => { + it("当收到 owner_changed 时,应触发从 core 的拉取式 re-sync,忽略帧里携带的 new_owner_member_id", async () => { + const { provider, org } = setup(); + // core 权威记录:owner_member_id=core_owner;而帧谎称 attacker_999。 + const http = { + apiPath: (path: string) => `/api/v1${path}`, + getForOrg: async (_orgId: string, path: string) => { + if (path.endsWith("/members/m_self")) return { display_name: "Codex", owner_member_id: "core_owner" }; + if (path.endsWith("/members/core_owner")) return { display_name: "Real Owner" }; + throw new Error(`unexpected ${path}`); + }, + }; + const resyncOwner = makeSyncSelf(http, provider, quietLog); + await handleConfigEvent( + provider, + org, + { event: "agent.config.owner_changed", data: { old_owner_member_id: "", new_owner_member_id: "attacker_999" } }, + { log: quietLog, resyncOwner }, + ); + expect(org.owner).toEqual({ member_id: "core_owner", name: "Real Owner" }); // 来自 core,而非帧 + }); +}); diff --git a/test/owner-sync.test.ts b/test/owner-sync.test.ts new file mode 100644 index 0000000..6b849bb --- /dev/null +++ b/test/owner-sync.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { writeFileSync, rmSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, type OrgConfig } from "../src/config.js"; +import { buildConfigProvider } from "../src/runtime-config.js"; +import { makeSyncSelf, type HttpForOrg } from "../src/owner-sync.js"; + +const quietLog = { info() {}, warn() {}, error() {} }; + +function baseConfig(owner = { member_id: "", name: "" }) { + return { + enabled: true, + server: { bff_url: "https://x", ws_url: "wss://x", frontend_base_path: "/workspace" }, + agent: { identity_id: "id_1", api_key: "cwsk_x", device_id: "dev_1", app_version: "codex-openmax/9.9.9" }, + orgs: { + org_1: { + enabled: true, + org_id: "org_1", + org_name: "Org One", + owner, + self: { member_id: "m_self", name: "Codex", display_name: "Codex" }, + access: { dmPolicy: "owner", dmAllowFrom: [], groupPolicy: "allowlist", groups: {} }, + }, + }, + codex: { bin: "codex", cwd: "/tmp" }, + bridge: { localHttpPort: 8787 }, + }; +} + +let currentPath = ""; +function setup(owner?: { member_id: string; name: string }) { + const dir = mkdtempSync(join(tmpdir(), "codex-ownersync-")); + const p = join(dir, "config.json"); + writeFileSync(p, JSON.stringify(baseConfig(owner))); + currentPath = p; + const cfg = loadConfig(p); + const provider = buildConfigProvider(cfg, p, quietLog); + return { p, provider, org: provider.enabledOrgs()[0], reloadedOrg: (): OrgConfig => loadConfig(p).orgs[0] }; +} +afterEach(() => { + if (currentPath) rmSync(currentPath, { force: true }); + currentPath = ""; +}); + +function fakeHttp(members: Record>): HttpForOrg { + return { + apiPath: (path: string) => `/api/v1${path}`, + getForOrg: async (_orgId: string, path: string) => { + const m = path.match(/\/members\/([^/]+)$/); + const id = m?.[1] ?? ""; + if (members[id]) return members[id]; + throw new Error(`404 ${path}`); + }, + }; +} + +describe("makeSyncSelf", () => { + it("当 core 返回 display_name 与 owner 时,应写回 self.display_name+owner,落盘,并返回 nameReady", async () => { + const { provider, org, reloadedOrg } = setup(); + const http = fakeHttp({ + m_self: { display_name: "Codex Prime", owner_member_id: "owner_1" }, + owner_1: { display_name: "Owner One" }, + }); + const res = await makeSyncSelf(http, provider, quietLog)(org); + expect(res).toEqual({ nameReady: true, displayName: "Codex Prime" }); + expect(org.self.display_name).toBe("Codex Prime"); + expect(org.owner).toEqual({ member_id: "owner_1", name: "Owner One" }); + // 已落盘且可 round-trip + expect(reloadedOrg().self.display_name).toBe("Codex Prime"); + expect(reloadedOrg().owner).toEqual({ member_id: "owner_1", name: "Owner One" }); + }); + + it("当 core 未记录 owner 时,绝不清空本地已绑定的 owner(首触自动绑定不变式)", async () => { + const { provider, org } = setup({ member_id: "local_owner", name: "Local" }); + const http = fakeHttp({ m_self: { display_name: "Codex", owner_member_id: "" } }); + const res = await makeSyncSelf(http, provider, quietLog)(org); + expect(res.nameReady).toBe(true); + expect(org.owner).toEqual({ member_id: "local_owner", name: "Local" }); // 未被清空 + }); + + it("当 self.member_id 缺失时,应返回 nameReady:false 并给出原因(不抛异常)", async () => { + const { provider, org } = setup(); + org.self = { member_id: "" }; + const res = await makeSyncSelf(fakeHttp({}), provider, quietLog)(org); + expect(res).toEqual({ nameReady: false, reason: expect.stringContaining("member_id") }); + }); + + it("当 core 请求失败时,应 fail-open 返回 nameReady:false,并保留本地 owner", async () => { + const { provider, org } = setup({ member_id: "local_owner", name: "Local" }); + const http: HttpForOrg = { + apiPath: (path: string) => `/api/v1${path}`, + getForOrg: async () => { + throw new Error("network down"); + }, + }; + const res = await makeSyncSelf(http, provider, quietLog)(org); + expect(res.nameReady).toBe(false); + expect(org.owner).toEqual({ member_id: "local_owner", name: "Local" }); + }); +}); diff --git a/test/version-check.test.ts b/test/version-check.test.ts new file mode 100644 index 0000000..1d1d339 --- /dev/null +++ b/test/version-check.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { compareSemver, resolveVersionCheckSchedule, readLocalVersion, makeVersionCheck, type CommForNotify } from "../src/version-check.js"; +import type { ConfigProvider } from "../src/runtime-config.js"; +import type { OrgConfig } from "../src/config.js"; + +const quietLog = { info() {}, warn() {}, error() {} }; + +describe("compareSemver(含 prerelease 排序)", () => { + it("当比较 prerelease 与正式版时,prerelease 应小于同核心的正式版", () => { + expect(compareSemver("0.1.0-alpha.3", "0.1.0")).toBe(-1); + expect(compareSemver("0.1.0", "0.1.0-alpha.3")).toBe(1); + }); + it("当比较两个 alpha 时,应按数字后缀排序", () => { + expect(compareSemver("0.1.0-alpha.3", "0.1.0-alpha.4")).toBe(-1); + expect(compareSemver("0.1.0-alpha.4", "0.1.0-alpha.3")).toBe(1); + }); + it("当核心版本不同时,应按主次修订排序", () => { + expect(compareSemver("0.2.0", "0.1.0")).toBe(1); + expect(compareSemver("0.1.0", "0.1.0")).toBe(0); + }); +}); + +describe("resolveVersionCheckSchedule", () => { + it("当未显式启用时,默认应为禁用", () => { + expect(resolveVersionCheckSchedule()).toEqual({ enabled: false }); + expect(resolveVersionCheckSchedule({})).toEqual({ enabled: false }); + expect(resolveVersionCheckSchedule({ intervalHours: 6 })).toEqual({ enabled: false }); + }); + it("当启用时,intervalHours 缺省应为 24,并换算出 intervalMs", () => { + expect(resolveVersionCheckSchedule({ enabled: true })).toEqual({ enabled: true, intervalHours: 24, intervalMs: 24 * 3600_000 }); + expect(resolveVersionCheckSchedule({ enabled: true, intervalHours: 6 })).toEqual({ enabled: true, intervalHours: 6, intervalMs: 6 * 3600_000 }); + }); +}); + +describe("readLocalVersion", () => { + it("应从仓库 package.json 读到与之一致的版本(验证相对路径深度)", () => { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }; + expect(readLocalVersion()).toBe(pkg.version); + }); +}); + +function stubProvider(orgs: Array>): ConfigProvider { + return { enabledOrgs: () => orgs as OrgConfig[] } as unknown as ConfigProvider; +} +function recordingComm(): { comm: CommForNotify; dms: string[]; sent: Array<{ conversationId: string; content: string }> } { + const dms: string[] = []; + const sent: Array<{ conversationId: string; content: string }> = []; + const comm: CommForNotify = { + createDm: async ({ peerMemberId }) => { + dms.push(peerMemberId); + return { conversation: { id: `dm_${peerMemberId}` } }; + }, + send: async (p) => { + sent.push(p); + }, + }; + return { comm, dms, sent }; +} + +describe("makeVersionCheck", () => { + it("当发现更新版时,应向 owner 发送一次通知,且重复轮询只通知一次", async () => { + const provider = stubProvider([{ org_id: "o1", owner: { member_id: "owner_1", name: "" } }]); + const { comm, sent } = recordingComm(); + const check = makeVersionCheck({ provider, comm, log: quietLog, localVersion: "0.1.0-alpha.3", fetchLatest: async () => "0.1.0-alpha.4" }); + await check(); + await check(); // 同一 latest:应被去重 + expect(sent).toHaveLength(1); + expect(sent[0].conversationId).toBe("dm_owner_1"); + expect(sent[0].content).toContain("0.1.0-alpha.3"); + expect(sent[0].content).toContain("0.1.0-alpha.4"); + }); + + it("当本地已是最新(相等或更旧)时,应不通知", async () => { + const provider = stubProvider([{ org_id: "o1", owner: { member_id: "owner_1", name: "" } }]); + const same = recordingComm(); + await makeVersionCheck({ provider, comm: same.comm, log: quietLog, localVersion: "0.1.0-alpha.3", fetchLatest: async () => "0.1.0-alpha.3" })(); + expect(same.sent).toHaveLength(0); + + const older = recordingComm(); + await makeVersionCheck({ provider, comm: older.comm, log: quietLog, localVersion: "0.2.0", fetchLatest: async () => "0.1.0" })(); + expect(older.sent).toHaveLength(0); + }); + + it("当 org 没有 owner.member_id 时,应跳过而不报错", async () => { + const provider = stubProvider([{ org_id: "o1", owner: { member_id: "", name: "" } }]); + const { comm, sent } = recordingComm(); + await makeVersionCheck({ provider, comm, log: quietLog, localVersion: "0.1.0", fetchLatest: async () => "0.2.0" })(); + expect(sent).toHaveLength(0); + }); + + it("当 fetch 失败时,应 fail-open 不抛异常且不通知", async () => { + const provider = stubProvider([{ org_id: "o1", owner: { member_id: "owner_1", name: "" } }]); + const { comm, sent } = recordingComm(); + await expect( + makeVersionCheck({ + provider, + comm, + log: quietLog, + localVersion: "0.1.0", + fetchLatest: async () => { + throw new Error("registry down"); + }, + })(), + ).resolves.toBeUndefined(); + expect(sent).toHaveLength(0); + }); +}); From c7afbde40f18e0073346b16e9c74c4fe038edd93 Mon Sep 17 00:00:00 2001 From: Noah-Bytes Date: Tue, 21 Jul 2026 23:43:19 +0800 Subject: [PATCH 2/2] chore: bump version to 0.1.0-alpha.4 Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69b9507..3e81e0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openmaxai/codex-openmax", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openmaxai/codex-openmax", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "dependencies": { "@openmaxai/openmax-agent-sdk": "^1.0.1" }, diff --git a/package.json b/package.json index 6ef53e5..c351543 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openmaxai/codex-openmax", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "private": false, "description": "Codex CLI ⇆ OpenMax/CWS channel adapter (Category B): Layer 1 shared SDK bridge + Layer 2 runtime adapter.", "repository": {