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
46 changes: 43 additions & 3 deletions config.example.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
{
"cws": { "bffUrl": "https://openmax.com", "wsUrl": "wss://openmax.com/ws", "identityId": "", "apiKey": "" },
"codex": { "bin": "codex", "cwd": "." },
"bridge": { "localHttpPort": 8787 }
"enabled": true,
"server": {
"bff_url": "https://your-cws-core.example.com",
"ws_url": "wss://your-cws-comm.example.com/cws-comm",
"frontend_base_path": "/workspace"
},
"agent": {
"identity_id": "",
"api_key": "cwsk_replace_me",
"device_id": "codex-openmax-device-1",
"app_version": "codex-openmax/0.1.0"
},
"cf_access": {
"client_id": "",
"client_secret": ""
},
"orgs": {
"019f0000-0000-7000-8000-000000000000": {
"enabled": true,
"org_id": "019f0000-0000-7000-8000-000000000000",
"org_name": "My Org",
"owner": { "member_id": "", "name": "" },
"self": { "member_id": "", "name": "Codex", "display_name": "" },
"access": {
"dmPolicy": "owner",
"dmAllowFrom": [],
"groupPolicy": "allowlist",
"groups": {
"019f0000-0000-7000-8000-conversation1": {
"mode": "mention",
"allowFrom": ["*"]
}
}
}
}
},
"codex": {
"bin": "codex",
"cwd": "."
},
"bridge": {
"localHttpPort": 8787
}
}
57 changes: 37 additions & 20 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
// graceful SIGINT/SIGTERM.
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import { readFileSync } from "node:fs";
import { buildConfig, writeConfigFile, type FetchLike, type OnboardInput } from "./onboarding.js";
import { loadConfig, type AppConfig } from "./config.js";

function usage(): never {
console.error(`usage:
Expand Down Expand Up @@ -57,63 +57,80 @@ async function cmdInit(args: string[]): Promise<void> {
} catch {
console.error("init: WARNING — `codex` binary not found on PATH; install it before `start`");
}
const org = (config.org as { self?: { display_name?: string } }).self?.display_name ?? "?";
const orgs = config.orgs as Record<string, { self?: { display_name?: string } }>;
const self = Object.values(orgs)[0]?.self?.display_name ?? "?";
// Machine-readable success line. No secrets: display name + org only.
console.log(JSON.stringify({ ok: true, org: input.org_id, self: org, codex: codexOk }));
console.log(JSON.stringify({ ok: true, org: input.org_id, self, codex: codexOk }));
} catch (e) {
console.error(`init: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
}
}

async function cmdStart(): Promise<void> {
let cfg: Record<string, unknown>;
let config: AppConfig;
try {
cfg = JSON.parse(readFileSync("config.json", "utf8")) as Record<string, unknown>;
} catch {
console.error("start: ./config.json missing or invalid — run `codex-openmax init` first");
config = loadConfig();
} catch (e) {
console.error(`start: ${e instanceof Error ? e.message : String(e)} — run \`codex-openmax init\` first`);
process.exit(1);
}
if (!(cfg.org as { org_id?: string } | undefined)?.org_id) {
console.error("start: config.json lacks an org block — re-run init");
if (!config.orgs.length) {
console.error("start: config.json has no orgs — re-run init");
process.exit(1);
}
// The SDK ships plain JS/ESM with no type declarations; construct via dynamic import.
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 cws = cfg.cws as { bffUrl: string; wsUrl: string; apiKey: string };
const org = cfg.org as { org_id: string; self: { member_id: string } };
const { server, agent, cfAccess, orgs } = config;
const log = (...a: unknown[]) => console.log(new Date().toISOString(), ...a);
const logger = { info: log, warn: log, error: log, debug: () => {}, log };
// 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 tokenManager = new sdk.TokenManager({
apiKey: cws.apiKey,
coreUrl: cws.bffUrl,
apiKey: agent.apiKey,
coreUrl: server.bffUrl,
...(cfAccessWrapped ? { cfAccess: cfAccessWrapped } : {}),
storage: sdk.memoryStorage(),
resolveDefaultOrgId: () => org.org_id,
resolveDefaultOrgId: defaultOrgId,
logger,
});
const http = new sdk.CwsHttpClient({
baseUrl: cws.bffUrl,
apiKey: cws.apiKey,
baseUrl: server.bffUrl,
apiKey: agent.apiKey,
deviceId: agent.deviceId,
clientVersion: agent.appVersion,
...(cfAccessWrapped ? { cfAccess: cfAccessWrapped } : {}),
frontendBasePath: server.frontendBasePath,
tokenManager,
resolveDefaultOrgId: () => org.org_id,
resolveDefaultOrgId: defaultOrgId,
logger,
});
const bridge = createSdkCwsBridge(
(deliver) =>
new sdk.CwsAgentBridge({
http,
tokenManager,
ws: { baseUrl: cws.wsUrl, deviceId: `codex-openmax-${org.self.member_id.slice(-6)}`, clientVersion: "codex-openmax/0.0.1" },
orgConfigs: [cfg.org],
ws: {
baseUrl: server.wsUrl,
deviceId: agent.deviceId,
clientVersion: agent.appVersion,
...(cfAccessWrapped ? { cfAccess: cfAccessWrapped } : {}),
},
orgConfigs: activeOrgs,
providers: { logger, inbound: { deliver } },
callbacks: { syncSelf: async () => ({ nameReady: true }) },
reporters: { metrics: false },
}),
);
const handle = await main(bridge);
log(`[codex-openmax] online — adapter on :${handle.port}, org=${org.org_id}`);
log(`[codex-openmax] online — adapter on :${handle.port}, orgs=${activeOrgs.map((o) => o.org_id).join(",")}`);
const stop = async () => {
log("[codex-openmax] stopping…");
await handle.stop();
Expand Down
111 changes: 99 additions & 12 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,98 @@
// Config load/validate: CWS credentials, codex binary, local HTTP port.
// Config load/validate: CWS server + agent credentials (bridge / openmax-mirrored
// format), per-org access policy, PLUS codex-openmax runtime-specific fields (the
// codex binary + the local HTTP/wake port).
//
// The on-disk config.json now mirrors the claude-openmax / openmax-bridge shape so
// the server/agent/orgs blocks are portable between the sibling adapters, with two
// codex-only additions (`codex`, `bridge`) that have no analog there:
//
// {
// "enabled": true,
// "server": { "bff_url", "ws_url", "frontend_base_path" },
// "agent": { "identity_id", "api_key", "device_id", "app_version" },
// "cf_access": { "client_id", "client_secret" }, // optional (test/CF envs)
// "orgs": { "<org_id>": { // KEYED by org_id
// "enabled", "org_id", "org_name",
// "owner": { "member_id", "name" },
// "self": { "member_id", "name", "display_name" },
// "access": { "dmPolicy", "dmAllowFrom", "groupPolicy", "groups" }
// } },
// "codex": { "bin", "cwd" }, // codex-openmax ONLY
// "bridge": { "localHttpPort" } // codex-openmax ONLY (local /wake server)
// }
//
// Secrets come from config.json / env and are never committed (see .gitignore, config.example.json).
// P0 finding: app-server speaks JSONL over stdio (no ws listener in codex-cli 0.136.0),
// so we configure the binary to spawn, not a URL — see docs/p0-spike-findings.md.
import { readFileSync } from "node:fs";

const DEFAULT_APP_VERSION = "codex-openmax/0.1.0";
const DEFAULT_FRONTEND_BASE_PATH = "/workspace";
const DEFAULT_DEVICE_ID = "codex-openmax-device";
const DEFAULT_LOCAL_HTTP_PORT = 8787;

/** Per-org access policy — full bridge shape (DM + group). Passed VERBATIM to the
* SDK's CwsAgentBridge as part of each orgConfig, so the SDK can enforce group
* access policy (the old `{dmPolicy}`-only shape could not express this). */
export interface OrgAccess {
dmPolicy?: string;
dmAllowFrom?: string[];
groupPolicy?: string;
groups?: Record<string, unknown>;
}

/** One org, in the bridge / openmax-mirrored shape handed straight to the SDK
* (orgConfigs). Kept snake_case because the SDK reads these keys directly. */
export interface OrgConfig {
enabled?: boolean;
org_id: string;
org_name?: string;
owner?: { member_id: string; name: string };
self: { member_id: string; name?: string; display_name?: string };
access: OrgAccess;
}

export interface AppConfig {
cws: { bffUrl: string; wsUrl: string; identityId: string; apiKey: string };
enabled?: boolean;
server: { bffUrl: string; wsUrl: string; frontendBasePath: string };
agent: { identityId: string; apiKey: string; deviceId: string; appVersion: string };
cfAccess?: { client_id: string; client_secret: string };
orgs: OrgConfig[];
// codex-openmax runtime-specific (no analog in claude-openmax / openmax):
codex: { bin: string; cwd: string };
bridge: { localHttpPort: number };
}

const REQUIRED = [
["cws.bffUrl", (c: AppConfig) => c.cws.bffUrl],
["cws.wsUrl", (c: AppConfig) => c.cws.wsUrl],
["cws.identityId", (c: AppConfig) => c.cws.identityId],
["cws.apiKey", (c: AppConfig) => c.cws.apiKey],
["server.bff_url", (c: AppConfig) => c.server.bffUrl],
["server.ws_url", (c: AppConfig) => c.server.wsUrl],
["agent.identity_id", (c: AppConfig) => c.agent.identityId],
["agent.api_key", (c: AppConfig) => c.agent.apiKey],
["codex.bin", (c: AppConfig) => c.codex.bin],
] as const;

/** Normalize the org_id-keyed on-disk `orgs` map into an array of orgConfigs. Each
* org's `access` block is passed through as-is (defaulting to {} when absent) so the
* full DM + group policy the config carries reaches the SDK unchanged. */
function parseOrgs(raw: Record<string, any>): OrgConfig[] {
const orgsRaw = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
const orgs: OrgConfig[] = [];
for (const [orgIdKey, org] of Object.entries(orgsRaw)) {
if (!org || typeof org !== "object") continue;
const org_id = (org as any).org_id || orgIdKey;
if (!org_id) continue;
orgs.push({
...((org as any).enabled !== undefined ? { enabled: (org as any).enabled } : {}),
org_id,
org_name: (org as any).org_name || "",
owner: (org as any).owner || { member_id: "", name: "" },
self: (org as any).self || { member_id: "", name: "", display_name: "" },
access: (org as any).access || {},
});
}
return orgs;
}

/**
* 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.
Expand All @@ -32,19 +107,31 @@ export function loadConfig(path?: string): AppConfig {
// default path missing is tolerated — env-only config is valid
}

const cfAccessRaw = raw.cf_access as { client_id?: string; client_secret?: string } | undefined;
const cfg: AppConfig = {
cws: {
bffUrl: process.env.CWS_BFF_URL ?? raw.cws?.bffUrl ?? "",
wsUrl: process.env.CWS_WS_URL ?? raw.cws?.wsUrl ?? "",
identityId: process.env.CWS_IDENTITY_ID ?? raw.cws?.identityId ?? "",
apiKey: process.env.CWS_API_KEY ?? raw.cws?.apiKey ?? "",
...(raw.enabled !== undefined ? { enabled: raw.enabled } : {}),
server: {
bffUrl: process.env.CWS_BFF_URL ?? raw.server?.bff_url ?? "",
wsUrl: process.env.CWS_WS_URL ?? raw.server?.ws_url ?? "",
frontendBasePath: raw.server?.frontend_base_path ?? DEFAULT_FRONTEND_BASE_PATH,
},
agent: {
identityId: process.env.CWS_IDENTITY_ID ?? raw.agent?.identity_id ?? "",
apiKey: process.env.CWS_API_KEY ?? raw.agent?.api_key ?? "",
deviceId: raw.agent?.device_id ?? DEFAULT_DEVICE_ID,
appVersion: raw.agent?.app_version ?? DEFAULT_APP_VERSION,
},
// Only surface cf_access when it actually carries a client_id — an empty
// { client_id: "", client_secret: "" } block (as in config.example.json) stays
// inert so we never emit empty CF-Access headers.
...(cfAccessRaw?.client_id ? { cfAccess: { client_id: cfAccessRaw.client_id, client_secret: cfAccessRaw.client_secret ?? "" } } : {}),
orgs: parseOrgs(raw.orgs),
codex: {
bin: process.env.CODEX_BIN ?? raw.codex?.bin ?? "codex",
cwd: process.env.CODEX_CWD ?? raw.codex?.cwd ?? process.cwd(),
},
bridge: {
localHttpPort: Number(process.env.BRIDGE_HTTP_PORT ?? raw.bridge?.localHttpPort ?? 8787),
localHttpPort: Number(process.env.BRIDGE_HTTP_PORT ?? raw.bridge?.localHttpPort ?? DEFAULT_LOCAL_HTTP_PORT),
},
};

Expand Down
49 changes: 32 additions & 17 deletions src/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export interface OnboardInput {

/** Full init pipeline → the config.json object (same shape the P1 stack already runs on).
* Dispatches on which credential shape `input` supplies — see OnboardInput. Validates fully
* before any network call either way (config.ts demands cws.identityId; a config missing it
* before any network call either way (config.ts demands agent.identity_id; a config missing it
* would be written only for `start` to reject it). */
export async function buildConfig(fetchFn: FetchLike, input: OnboardInput): Promise<Record<string, unknown>> {
const { api_key: apiKey, identity_id: identityId, invitation_id: invitationId, invitation_token: invitationToken } = input;
Expand All @@ -241,16 +241,7 @@ export async function buildConfig(fetchFn: FetchLike, input: OnboardInput): Prom
if (!identityId) throw new Error("missing required field: identity_id");
const jwt = await exchangeAgentToken(fetchFn, input.bff_url, apiKey, input.org_id);
const self = await fetchSelf(fetchFn, input.bff_url, jwt);
return {
cws: { bffUrl: input.bff_url, wsUrl: input.ws_url, identityId, apiKey },
codex: { bin: "codex", cwd: "." },
bridge: { localHttpPort: input.local_http_port ?? 8787 },
org: {
org_id: input.org_id,
self: { member_id: self.memberId, display_name: self.displayName },
access: { dmPolicy: "open" },
},
};
return assembleConfig(input, identityId, apiKey, input.org_id, self);
}

// Self-register shape: register -> identity-only JWT -> accept invitation -> org JWT.
Expand All @@ -263,15 +254,39 @@ export async function buildConfig(fetchFn: FetchLike, input: OnboardInput): Prom
// caller-supplied input.org_id — trust the accept response over the reference value.
const orgJwt = await exchangeAgentToken(fetchFn, input.bff_url, registered.apiKey, accepted.orgId);
const self = await fetchSelf(fetchFn, input.bff_url, orgJwt);
return assembleConfig(input, registered.identityId, registered.apiKey, accepted.orgId, self);
}

/** Assemble the config.json object in the bridge / openmax-mirrored shape (see
* config.ts for the full field map), keeping the codex-openmax runtime-specific
* blocks (`codex`, `bridge`). The per-org `access` block is emitted COMPLETE
* ({dmPolicy, dmAllowFrom, groupPolicy, groups}) with claude-openmax's onboarding
* defaults — a private-by-default posture the old `{dmPolicy:"open"}` could not
* express — so an operator can widen it later without hand-editing the shape. */
function assembleConfig(input: OnboardInput, identityId: string, apiKey: string, orgId: string, self: SelfInfo): Record<string, unknown> {
return {
cws: { bffUrl: input.bff_url, wsUrl: input.ws_url, identityId: registered.identityId, apiKey: registered.apiKey },
enabled: true,
server: { bff_url: input.bff_url, ws_url: input.ws_url, frontend_base_path: "/workspace" },
agent: {
identity_id: identityId,
api_key: apiKey,
// Global device id derived from the resolved member_id (preserves the old
// per-org derivation as a stable, human-recognizable default).
device_id: `codex-openmax-${self.memberId.slice(-6)}`,
app_version: "codex-openmax/0.1.0",
},
orgs: {
[orgId]: {
enabled: true,
org_id: orgId,
org_name: "",
owner: { member_id: "", name: "" },
self: { member_id: self.memberId, name: self.displayName, display_name: self.displayName },
access: { dmPolicy: "owner", dmAllowFrom: [], groupPolicy: "allowlist", groups: {} },
},
},
codex: { bin: "codex", cwd: "." },
bridge: { localHttpPort: input.local_http_port ?? 8787 },
org: {
org_id: accepted.orgId,
self: { member_id: self.memberId, display_name: self.displayName },
access: { dmPolicy: "open" },
},
};
}

Expand Down
Loading
Loading