Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
59 changes: 54 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -83,16 +92,20 @@ async function cmdStart(): Promise<void> {
const sdk = (await import("@openmaxai/openmax-agent-sdk")) as Record<string, any>;
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,
Expand All @@ -112,6 +125,9 @@ async function cmdStart(): Promise<void> {
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({
Expand All @@ -125,14 +141,47 @@ async function cmdStart(): Promise<void> {
},
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);
};
Expand Down
196 changes: 196 additions & 0 deletions src/config-events.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

export interface ConfigEventDeps {
log: Logger;
/** Pull-based owner re-sync for owner_changed (typically owner-sync's syncSelf). */
resyncOwner?: (orgConfig: OrgConfig) => Promise<unknown>;
}

/**
* 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<void> {
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<OrgAccess["groups"]> = {};
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;
}
}
27 changes: 25 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface OrgAccess {
dmPolicy?: string;
dmAllowFrom?: string[];
groupPolicy?: string;
groups?: Record<string, unknown>;
groups?: Record<string, { mode?: string; allowFrom?: string[] }>;
}

/** One org, in the bridge / openmax-mirrored shape handed straight to the SDK
Expand All @@ -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 = [
Expand Down Expand Up @@ -93,12 +97,21 @@ function parseOrgs(raw: Record<string, any>): 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<string, any> = {};
try {
raw = JSON.parse(readFileSync(file, "utf8"));
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading