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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ required):
UNIVERSAL=1 bun run package:macos
```

`package:macos` requires a clean git working tree so a release archive cannot include uncommitted files. Use `bun run build:macos` for a local development build from a dirty checkout.

Every built app launches only the Bun runtime and server resources embedded in its own
`Contents/Resources/runtime`; it never executes checkout `src/` or an ambient `ccx`. Rebuild the app
to pick up source changes. If startup fails, the menu app stays open so its diagnostics and **Start**
Expand Down
3 changes: 1 addition & 2 deletions docs-site/src/content/docs/guides/macos-menu-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,7 @@ workflow does not install or copy the app into Application
Support. A rebuild at the same path is detected on the
next launch and refreshes the existing Login Item registration only when Launch at Login remains on.
Each build stamps its exact Git revision into `CodexCommanderSourceRevision` in the bundle's `Info.plist`
and prints it at the end of the build. Uncommitted source is marked with `-dirty`, so commit before
making a final distributable bundle.
and prints it at the end of the build. Uncommitted source is marked with `-dirty`. `bun run package:macos` refuses a dirty working tree so a distributable archive cannot include uncommitted files; commit first, or use `bun run build:macos` for a local development build.

The source-build `.app` is a thin development artifact for the current checkout, not the public
distribution format. Use the universal release archive for public installation. A source app in the
Expand Down
26 changes: 26 additions & 0 deletions gui/src/api-access-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ export function classifyExternalModel(row: {
};
}

/**
* Classify a management `/api/models` row for the API-keys catalog.
* Disabled rows are omitted: they are not callable on the data plane.
*/
export function classifyManagementModel(row: unknown): ExternalModelRow | null {
if (row === null || typeof row !== "object" || Array.isArray(row)) return null;
const data = row as Record<string, unknown>;
if (data.disabled === true) return null;
if (typeof data.id !== "string" || data.id.length === 0) return null;
const namespaced = typeof data.namespaced === "string" && data.namespaced.length > 0
? data.namespaced
: data.id;
const provider = typeof data.provider === "string" && data.provider.length > 0
? data.provider
: (namespaced.includes("/") ? namespaced.slice(0, namespaced.indexOf("/")) : "openai");
return {
id: namespaced,
displayName: typeof data.displayName === "string" && data.displayName.length > 0
? data.displayName
: namespaced,
provider,
native: data.native === true,
custom: data.custom === true,
};
}

export function externalModelId(model: ExternalModelRow): string {
return model.id;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useT, type TKey } from "../../i18n/shared";
import { useCopyFeedback } from "../use-copy-feedback";
import {
isConfirmedGuiLaunch,
subscribeGuiLaunchCapability,
whenGuiLaunchCapabilitySettles,
} from "../../api";
import type { CodexDelegationSetupController, CodexDelegationStatus } from "../../pages/use-codex-delegation-setup";

function statusKey(status: CodexDelegationStatus): TKey {
Expand Down Expand Up @@ -41,11 +46,18 @@ export default function CodexDelegationSetupCard({ delegationSetup }: { delegati
const previewConfirmRef = useRef<HTMLButtonElement>(null);
const removeTriggerRef = useRef<HTMLButtonElement>(null);
const copyFeedback = useCopyFeedback<CodexDelegationStatus["copyPrompts"][keyof CodexDelegationStatus["copyPrompts"]]>();
const [confirmedLaunch, setConfirmedLaunch] = useState(isConfirmedGuiLaunch);
useEffect(() => {
const update = () => setConfirmedLaunch(isConfirmedGuiLaunch());
const unsubscribe = subscribeGuiLaunchCapability(update);
void whenGuiLaunchCapabilitySettles().then(update);
return unsubscribe;
}, []);
const blocked = status?.state === "conflict" || status?.state === "unsafe";
const canMutate = loaded && !!status && !blocked && !busy;
const canMutate = loaded && !!status && !blocked && !busy && confirmedLaunch;
const installed = status?.state === "current";
const removable = !!status && isRemovable(status);
const canRemove = removable && !busy;
const canRemove = removable && !busy && confirmedLaunch;
const primaryKey = status?.state === "update-available" ? "sub.delegationSetup.update"
: status?.state === "partial" ? "sub.delegationSetup.repair" : "sub.delegationSetup.install";
const prompt = status?.copyPrompts[selectedMode] ?? "";
Expand Down Expand Up @@ -92,6 +104,7 @@ export default function CodexDelegationSetupCard({ delegationSetup }: { delegati
<li><span>{t("sub.delegationSetup.agentsArtifact")}</span><code>{status.artifacts.agentsPolicy.displayPath}</code></li>
</ul>
{blocked && <p className="swi-delegation-blocked" role="alert">{t(blockedReason(status))}</p>}
{!confirmedLaunch && <p className="swi-delegation-blocked" role="status">{t("sub.delegationSetup.launcherRequired")}</p>}
{error && <div className="swi-delegation-retry"><p className="swi-delegation-error" role="alert">{t("sub.delegationSetup.error")}</p><button type="button" className="btn btn-ghost btn-sm" disabled={busy} onClick={() => { void delegationSetup.reload(); }}>{t("sub.delegationSetup.retry")}</button></div>}
<div className="swi-delegation-actions">
<button type="button" className="btn btn-ghost btn-sm" disabled={busy} onClick={event => openPreview(event, false)}>{t("sub.delegationSetup.preview")}</button>
Expand All @@ -103,8 +116,8 @@ export default function CodexDelegationSetupCard({ delegationSetup }: { delegati
{success && <p className="swi-delegation-working" role="status">{t("sub.delegationSetup.newTask")}</p>}
<details className="swi-delegation-manual"><summary>{t("sub.delegationSetup.manual")}</summary><div><p>{t("sub.delegationSetup.manualHint")}</p><button type="button" className="btn btn-ghost btn-sm" disabled={!prompt} onClick={() => copyFeedback.copy(prompt, prompt)}>{t(copyOutcome === "copied" ? "sub.delegationSetup.copied" : copyOutcome === "unavailable" ? "sub.delegationSetup.copyUnavailable" : "sub.delegationSetup.copy")}</button></div></details>
</div>}
{previewOpen && status && <div className="dialog-backdrop" onMouseDown={closePreview}><div className="dialog swi-delegation-dialog" role="dialog" aria-modal="true" aria-labelledby="delegation-preview-title" aria-describedby="delegation-preview-copy" onKeyDown={trap} onMouseDown={event => event.stopPropagation()}><h3 id="delegation-preview-title">{t("sub.delegationSetup.preview")}</h3><div id="delegation-preview-copy"><pre>{status.previews[selectedMode].skillText}</pre><pre>{status.previews[selectedMode].agentsBlockText}</pre></div><div className="swi-delegation-actions"><button type="button" className="btn btn-ghost btn-sm" onClick={closePreview}>{t("sub.delegationSetup.close")}</button>{previewApply && <button ref={previewConfirmRef} type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => { void runInstall(); }}>{installed ? t("sub.delegationSetup.confirmChangeMode") : t(primaryKey)}</button>}</div></div></div>}
{removeOpen && <div className="dialog-backdrop" onMouseDown={closeRemove}><div className="dialog swi-delegation-dialog" role="alertdialog" aria-modal="true" aria-labelledby="delegation-remove-title" aria-describedby="delegation-remove-copy" onKeyDown={trap} onMouseDown={event => event.stopPropagation()}><h3 id="delegation-remove-title">{t("sub.delegationSetup.removeTitle")}</h3><p id="delegation-remove-copy">{t("sub.delegationSetup.removeConfirm")}</p>{error && <p className="swi-delegation-error" role="alert">{t("sub.delegationSetup.error")}</p>}<div className="swi-delegation-actions"><button type="button" className="btn btn-ghost btn-sm" onClick={closeRemove}>{t("sub.delegationSetup.cancel")}</button><button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => { void runRemove(); }}>{t("sub.delegationSetup.remove")}</button></div></div></div>}
{previewOpen && status && <div className="dialog-backdrop" onMouseDown={closePreview}><div className="dialog swi-delegation-dialog" role="dialog" aria-modal="true" aria-labelledby="delegation-preview-title" aria-describedby="delegation-preview-copy" onKeyDown={trap} onMouseDown={event => event.stopPropagation()}><h3 id="delegation-preview-title">{t("sub.delegationSetup.preview")}</h3><div id="delegation-preview-copy"><pre>{status.previews[selectedMode].skillText}</pre><pre>{status.previews[selectedMode].agentsBlockText}</pre></div><div className="swi-delegation-actions"><button type="button" className="btn btn-ghost btn-sm" onClick={closePreview}>{t("sub.delegationSetup.close")}</button>{previewApply && <button ref={previewConfirmRef} type="button" className="btn btn-primary btn-sm" disabled={busy || !confirmedLaunch} onClick={() => { void runInstall(); }}>{installed ? t("sub.delegationSetup.confirmChangeMode") : t(primaryKey)}</button>}</div></div></div>}
{removeOpen && <div className="dialog-backdrop" onMouseDown={closeRemove}><div className="dialog swi-delegation-dialog" role="alertdialog" aria-modal="true" aria-labelledby="delegation-remove-title" aria-describedby="delegation-remove-copy" onKeyDown={trap} onMouseDown={event => event.stopPropagation()}><h3 id="delegation-remove-title">{t("sub.delegationSetup.removeTitle")}</h3><p id="delegation-remove-copy">{t("sub.delegationSetup.removeConfirm")}</p>{error && <p className="swi-delegation-error" role="alert">{t("sub.delegationSetup.error")}</p>}<div className="swi-delegation-actions"><button type="button" className="btn btn-ghost btn-sm" onClick={closeRemove}>{t("sub.delegationSetup.cancel")}</button><button type="button" className="btn btn-primary btn-sm" disabled={busy || !confirmedLaunch} onClick={() => { void runRemove(); }}>{t("sub.delegationSetup.remove")}</button></div></div></div>}
</section>
);
}
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2247,4 +2247,5 @@ export const de: Record<TKey, string> = {
"sub.delegationSetup.close": "Schließen",
"sub.delegationSetup.cancel": "Abbrechen",
"sub.delegationSetup.confirmChangeMode": "Modus ändern",
"sub.delegationSetup.launcherRequired": "Dieses Dashboard ist für Installieren, Aktualisieren und Entfernen schreibgeschützt. Öffne es mit `ccx gui` oder über die CodexCommander-Menüleiste, um diese Änderungen zu bestätigen.",
};
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2275,6 +2275,7 @@ export const en = {
"sub.delegationSetup.close": "Close",
"sub.delegationSetup.cancel": "Cancel",
"sub.delegationSetup.confirmChangeMode": "Change mode",
"sub.delegationSetup.launcherRequired": "This dashboard is read-only for Install, Update, and Remove. Open it with `ccx gui` or from the CodexCommander menu bar app to confirm those changes.",

} as const;

Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2267,4 +2267,5 @@ export const ja: Record<TKey, string> = {
"sub.delegationSetup.close": "閉じる",
"sub.delegationSetup.cancel": "キャンセル",
"sub.delegationSetup.confirmChangeMode": "モードを変更",
"sub.delegationSetup.launcherRequired": "このダッシュボードではインストール、更新、削除は読み取り専用です。`ccx gui` または CodexCommander メニューバーアプリから開いて、これらの変更を確認してください。",
};
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2267,5 +2267,6 @@ export const ko: Record<TKey, string> = {
"sub.delegationSetup.close": "닫기",
"sub.delegationSetup.cancel": "취소",
"sub.delegationSetup.confirmChangeMode": "모드 변경",
"sub.delegationSetup.launcherRequired": "이 대시보드에서는 설치, 업데이트, 제거가 읽기 전용입니다. `ccx gui` 또는 CodexCommander 메뉴 막대 앱에서 열어 해당 변경을 확인하세요.",

};
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2269,4 +2269,5 @@ export const ru: Record<TKey, string> = {
"sub.delegationSetup.close": "Закрыть",
"sub.delegationSetup.cancel": "Отмена",
"sub.delegationSetup.confirmChangeMode": "Сменить режим",
"sub.delegationSetup.launcherRequired": "Эта панель только для чтения при установке, обновлении и удалении. Откройте её через `ccx gui` или приложение CodexCommander в строке меню, чтобы подтвердить эти изменения.",
};
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2267,4 +2267,5 @@ export const zh: Record<TKey, string> = {
"sub.delegationSetup.close": "关闭",
"sub.delegationSetup.cancel": "取消",
"sub.delegationSetup.confirmChangeMode": "更改模式",
"sub.delegationSetup.launcherRequired": "此仪表板的安装、更新和移除为只读。请通过 `ccx gui` 或 CodexCommander 菜单栏应用打开,以确认这些更改。",
};
21 changes: 6 additions & 15 deletions gui/src/pages/ApiKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useI18n, LOCALES } from "../i18n/shared";
import { formatProviderDisplayName } from "../provider-icons";
import { readJsonIfOk, readJsonOrThrow } from "../fetch-json";
import {
classifyExternalModel,
classifyManagementModel,
externalModelId,
type ExternalModelRow,
type GatewayInboundProtocol,
Expand Down Expand Up @@ -153,22 +153,13 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
}, [apiBase, keysCacheKey, t]);

const fetchModels = useCallback(async (signal: AbortSignal): Promise<ExternalModelRow[]> => {
const res = await fetch(`${apiBase}/v1/models`, { signal });
const res = await fetch(`${apiBase}/api/models`, { signal });
if (!res.ok) throw new Error(t("api.modelsLoadFailed"));
const data = await res.json() as unknown;
const rawRows = Array.isArray(data)
? data
: (typeof data === "object" && data !== null && Array.isArray((data as { data?: unknown }).data)
? (data as { data: unknown[] }).data
: null);
if (!rawRows) throw new Error(t("api.modelsLoadFailed"));
const rows = rawRows
.filter((row): row is { id: string; owned_by?: string } => (
typeof row === "object"
&& row !== null
&& typeof (row as { id?: unknown }).id === "string"
))
.map(row => classifyExternalModel(row))
if (!Array.isArray(data)) throw new Error(t("api.modelsLoadFailed"));
const rows = data
.map(row => classifyManagementModel(row))
.filter((row): row is ExternalModelRow => row !== null)
.sort((a, b) => externalModelId(a).localeCompare(externalModelId(b)));
writeSessionListCache(modelsCacheKey, rows);
return rows;
Expand Down
18 changes: 18 additions & 0 deletions gui/tests/api-access-models.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
classifyExternalModel,
classifyManagementModel,
gatewayInboundProtocols,
} from "../src/api-access-models";

Expand Down Expand Up @@ -46,6 +47,23 @@ describe("classifyExternalModel", () => {
});
});

describe("classifyManagementModel", () => {
test("uses the namespaced callable id and drops disabled rows", () => {
expect(classifyManagementModel({
id: "opus-4-6", namespaced: "anthropic/opus-4-6", provider: "anthropic", custom: true, disabled: false,
})).toEqual({
id: "anthropic/opus-4-6",
displayName: "anthropic/opus-4-6",
provider: "anthropic",
native: false,
custom: true,
});
expect(classifyManagementModel({
id: "gpt-5.4", namespaced: "gpt-5.4", provider: "openai", native: true, disabled: true,
})).toBeNull();
});
});

describe("gatewayInboundProtocols", () => {
test("lists gateway protocols and hides Messages when Claude inbound is off", () => {
expect(gatewayInboundProtocols(true)).toEqual(["responses", "chat", "messages"]);
Expand Down
4 changes: 3 additions & 1 deletion gui/tests/apikeys-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ test("ApiKeys workspace keeps endpoint, generate, models, and usage panels", asy
expect(between).not.toContain('t("api.usageChatTitle")');
expect(between).not.toContain('t("api.usageResponsesTitle")');
expect(src).toContain("gatewayInboundProtocols(claudeCodeEnabled)");
expect(page).toContain("classifyExternalModel(row)");
expect(page).toContain("classifyManagementModel(row)");
expect(page).toContain("${apiBase}/api/models");
expect(page).not.toContain("${apiBase}/v1/models");
expect(page).toContain('from "../api-access-models"');
});

Expand Down
4 changes: 2 additions & 2 deletions gui/tests/apikeys-model-test-wire.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ function installFetch(sent: SentRequest[], dataPlaneStatus = 200): void {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
if (url.endsWith("/v1/models") && method === "GET") {
return Response.json({ data: [{ id: "gpt-5.4", owned_by: "openai" }] });
if (url.endsWith("/api/models") && method === "GET") {
return Response.json([{ id: "gpt-5.4", namespaced: "gpt-5.4", provider: "openai", native: true, disabled: false }]);
}
if (url.endsWith("/api/keys") && method === "GET") return Response.json(KEYS_OK);
if (url.endsWith("/api/keys") && method === "POST") return Response.json({ key: ONE_TIME_KEY });
Expand Down
17 changes: 9 additions & 8 deletions gui/tests/apikeys-models-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function installFetch(models: () => Response, counter: { gets: number }): void {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
if (url.endsWith("/v1/models") && method === "GET") {
if (url.endsWith("/api/models") && method === "GET") {
counter.gets += 1;
return models();
}
Expand Down Expand Up @@ -121,7 +121,7 @@ function retryButton(container: HTMLDivElement): HTMLButtonElement | undefined {

test("an empty catalog says the catalog is empty, with no query in the sentence", async () => {
const counter = { gets: 0 };
installFetch(() => Response.json({ data: [] }), counter);
installFetch(() => Response.json([]), counter);
const { container, root } = await mountPage();
try {
expect(container.textContent).toContain("No externally callable models are available yet.");
Expand All @@ -134,9 +134,10 @@ test("an empty catalog says the catalog is empty, with no query in the sentence"

test("a query matching nothing names the query, and does not claim the catalog is empty", async () => {
const counter = { gets: 0 };
installFetch(() => Response.json({
data: [{ id: "gpt-5.4", owned_by: "openai" }, { id: "claude/opus-4-6", owned_by: "anthropic" }],
}), counter);
installFetch(() => Response.json([
{ id: "gpt-5.4", namespaced: "gpt-5.4", provider: "openai", native: true, disabled: false },
{ id: "opus-4-6", namespaced: "claude/opus-4-6", provider: "anthropic", custom: true, disabled: false },
]), counter);
const { container, root } = await mountPage();
try {
expect(container.textContent).toContain("gpt-5.4");
Expand All @@ -158,7 +159,7 @@ test("a failed cold load offers a retry that really refetches, and no false empt
installFetch(
() => (fail
? new Response("upstream unavailable", { status: 503 })
: Response.json({ data: [{ id: "gpt-5.4", owned_by: "openai" }] })),
: Response.json([{ id: "gpt-5.4", namespaced: "gpt-5.4", provider: "openai", native: true, disabled: false }])),
counter,
);
const { container, root } = await mountPage();
Expand Down Expand Up @@ -220,12 +221,12 @@ test("a failure is announced, and a cache-backed retry shows progress without lo
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
if (url.endsWith("/v1/models") && method === "GET") {
if (url.endsWith("/api/models") && method === "GET") {
counter.gets += 1;
if (counter.gets === 1) return new Response("upstream unavailable", { status: 503 });
// Hold the retry open so the in-flight state is observable.
await new Promise<void>(resolve => { release = resolve; });
return Response.json({ data: [{ id: "fresh-model", owned_by: "openai" }] });
return Response.json([{ id: "fresh-model", namespaced: "fresh-model", provider: "openai", native: true, disabled: false }]);
}
if (url.endsWith("/api/keys") && method === "GET") return Response.json(KEYS_OK);
return new Response(null, { status: 404 });
Expand Down
Loading