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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

### Fixed

- Explain Codex ChatGPT-login model rejections with localized recovery and distinguish model catalog discovery from verified access (#613).
- #615: Validate Windows process ancestry by creation time so reused parent PIDs cannot attach unrelated processes to the packaged-app smoke test or its cleanup tree.
- #611: Preserve complete Codex employee questions and thread context on Windows by sending prompts through stdin. Chinese, multiline and long requests no longer lose their content through `.cmd` launchers; input-pipe failures produce a typed error.
- #609: Refresh the Codex employee model picker from the installed CLI's model catalog, preserve saved selections, and show stale/unavailable fallback status instead of silently relying on an outdated disk cache.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ Useful commands from the repository root:

## Current limits

For Codex employees, the model picker queries the installed Codex CLI's `model/list` catalog and caches results briefly. Use **Refresh model list** to retry discovery. Failed discovery keeps the previous list or local disk cache and shows a fallback status; saved model choices remain available. A listed model does not guarantee inference access for the account or provider.
For Codex employees, the model picker queries the installed Codex CLI's `model/list` catalog and caches results briefly. Use **Refresh model list** to retry discovery. Failed discovery keeps the previous list or local disk cache and shows a fallback status; saved model choices remain available. A listed or recommended model does not guarantee inference access for the account or provider; refreshing the list does not verify that access. If Codex rejects a model for the ChatGPT login, the conversation explains the rejection and the picker flags the last matching failed request. Choose another employee model or check the login in the same Codex CLI before resending. RoleWeave keeps the saved model and never switches to API billing automatically.

- Real Qoder execution has been verified on a macOS machine. Claude Code live execution, full delegation chains, and end-to-end long-term context workflows are not yet part of the verified baseline.
- macOS has a signed-manifest update mechanism. New Windows builds can check, download, and install unsigned NSIS updates after user confirmation. The downloaded installer is checked against SHA-512 metadata fetched over HTTPS; there is no independent publisher signature on this channel. Publishing a release exposes its metadata to clients checking these channels.
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/renderer/src/turns/ConversationOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ export function ConversationOptions({ config, engine, saving, disabled, loading
const contextPanel = useRef<HTMLElement | null>(null);
const usagePanel = useRef<HTMLElement | null>(null);
const completed = turns.filter((turn) => !turn.provisional && turn.status !== "running");
const codex = engine === "codex" || engine === "codex-local";
const lastModelAttempt = codex ? completed.filter((turn) => turn.engine === engine && turn.model === config?.selected).at(-1) : undefined;
const modelWasRejected = lastModelAttempt?.status === "failed" && lastModelAttempt.errorCode === "codex.model_access_denied";
const reported = completed.filter((turn) => turn.totalTokens !== undefined);
const total = reported.reduce((sum, turn) => sum + turn.totalTokens!, 0);
// The latest completed turn is authoritative. Do not surface an older
Expand Down Expand Up @@ -211,6 +214,8 @@ export function ConversationOptions({ config, engine, saving, disabled, loading
</Tooltip> : null}
</div>
{config.catalogStatus && config.catalogStatus !== "ready" ? <p className="owb-model-menu__catalog-status" role="status">{t(`model.catalog.${config.catalogStatus}`)}</p> : null}
{codex ? <p className="owb-model-menu__catalog-status" role="note">{t("model.codexAccessUnverified")}</p> : null}
{modelWasRejected ? <p className="owb-model-menu__catalog-status" role="alert">{t("model.codexLastRejected")}</p> : null}
{connection ? <div className="owb-model-menu__connection"><ConnectionSummary connection={connection} /></div> : null}
{menu}
{config.allowCustomModel && !connectionInvalid && onModel ? <CustomModelEntry strict={config.customModelFormat === "strict"} /> : null}
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/renderer/src/turns/TurnThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,9 @@ export function TurnThread({ turns, loading = false, onEdit, viewportMemory, ret
<ol ref={threadRef} className={`owb-turn-thread${turns.length === 0 ? " owb-turn-thread--empty" : ""}`} role="log" aria-live="polite" aria-label={t("turn.threadAria")} hidden={turns.length === 0}>
{turns.map((turn) => {
const retryable = turn.status === "failed" || turn.status === "indeterminate";
const modelAccessDenied = (turn.engine === "codex" || turn.engine === "codex-local") && turn.errorCode === "codex.model_access_denied";
const failureMessage = modelAccessDenied
? t("turn.codexModelAccessDenied", { model: turn.model ?? t("model.agentDefault") }) : turn.error;
const stateClass =
turn.status === "running"
? "is-running"
Expand Down Expand Up @@ -573,12 +576,12 @@ export function TurnThread({ turns, loading = false, onEdit, viewportMemory, ret

{turn.status === "completed" && hasUnexecutedToolText(turn.output) ? <p className="owb-turn__warning" role="alert">{t("turn.toolsNotExecutedHelp")}</p> : null}
{turn.error ? (
<div className="owb-bubble__error owb-turn-failure" role="alert" title={turn.error}>
<div className="owb-bubble__error owb-turn-failure" role="alert" title={failureMessage}>
<div className="owb-turn-failure__title"><AlertTriangle aria-hidden="true" size={14} />
{t(turn.errorCode === "turn_timeout" ? "turn.timeoutTitle" : "turn.failedTitle")}
{t(modelAccessDenied ? "turn.modelAccessDeniedTitle" : turn.errorCode === "turn_timeout" ? "turn.timeoutTitle" : "turn.failedTitle")}
</div>
{turn.errorCode === "turn_timeout" && turn.output ? <p>{t("turn.timeoutPreserved")}</p> : turn.error !== t("turn.failedTitle") ? <p>{turn.error}</p> : null}
{turn.errorCode !== "turn_timeout" ? <small>{t("turn.failedHelp")}</small> : null}
{turn.errorCode === "turn_timeout" && turn.output ? <p>{t("turn.timeoutPreserved")}</p> : failureMessage !== t("turn.failedTitle") ? <p>{failureMessage}</p> : null}
{turn.errorCode !== "turn_timeout" ? <small>{t(modelAccessDenied ? "turn.codexModelAccessHelp" : "turn.failedHelp")}</small> : null}
</div>
) : null}
{turn.diagnostic ? (
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/renderer/test/codex-model-access.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { OwbI18nProvider } from "@roleweave/ui";
import type { EmployeeModelConfig } from "@roleweave/shared";
import { ConversationOptions } from "../src/turns/ConversationOptions";
import { TurnThread } from "../src/turns/TurnThread";
import type { TurnRecord } from "../src/turns/types";

const config: EmployeeModelConfig = {
selected: "gpt-6-luna", recommended: "gpt-6-luna", editable: true, source: "provider-catalog", catalogStatus: "ready",
options: [{ id: "gpt-6-luna", name: "GPT-6-Luna", tier: "economy" }, { id: "another-model", name: "Another model", tier: "default" }],
};
function rejected(overrides: Partial<TurnRecord> = {}): TurnRecord {
return { id: "rejected", positionId: "lead", positionName: "Lead", engine: "codex-local", model: "gpt-6-luna",
input: "Where is the workspace?", status: "failed", createdAt: "2026-10-19T07:08:44Z", completedAt: "2026-10-19T07:08:50Z",
errorCode: "codex.model_access_denied", error: "Provider diagnostic placeholder", ...overrides };
}

it.each(["zh-CN", "en"] as const)("explains the requested model rejection and recovery in %s", locale => {
const retry = vi.fn();
render(<OwbI18nProvider locale={locale}><TurnThread turns={[rejected()]} onRetry={retry} /></OwbI18nProvider>);
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent(locale === "en" ? "Model access denied" : "模型访问被拒绝");
expect(alert).toHaveTextContent("gpt-6-luna");
expect(alert).toHaveTextContent(locale === "en" ? "ChatGPT login" : "ChatGPT 登录");
expect(alert).toHaveTextContent(locale === "en" ? "same Codex CLI" : "同一 Codex CLI");
expect(alert).not.toHaveTextContent("Provider diagnostic placeholder");
expect(alert).not.toHaveTextContent(locale === "en" ? "adjust the task" : "调整任务");
expect(retry).not.toHaveBeenCalled();
});

it.each(["codex", "codex-local"] as const)("distinguishes discovery from access and permits an explicit model change for %s", async engine => {
const change = vi.fn(), refresh = vi.fn();
const { rerender } = render(<ConversationOptions config={config} engine={engine} saving={false} disabled={false} session={null}
turns={[rejected({ engine })]} onModel={change} onReload={refresh} />);
const select = screen.getByRole("combobox", { name: "员工模型" });
fireEvent.mouseDown(select);
expect(await screen.findByRole("note")).toHaveTextContent("推荐模型尚未验证");
expect(screen.getByRole("note")).toHaveTextContent("刷新列表不会校验调用权限");
expect(screen.getByRole("alert")).toHaveTextContent("上次调用");
expect(screen.getByRole("option", { name: "GPT-6-Luna", selected: true })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "刷新模型列表" }));
expect(refresh).toHaveBeenCalledTimes(1);
expect(change).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Another model", { selector: ".owb-model-choice > span" }));
expect(change).toHaveBeenCalledExactlyOnceWith("another-model");
rerender(<ConversationOptions config={{ ...config, selected: "another-model" }} engine={engine} saving={false} disabled={false}
session={null} turns={[rejected({ engine })]} onModel={change} onReload={refresh} />);
fireEvent.mouseDown(select);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByRole("note")).toHaveTextContent("尚未验证");
});

it("shows the access caveat in English even when discovery is unavailable", async () => {
render(<OwbI18nProvider locale="en"><ConversationOptions config={{ ...config, catalogStatus: "unavailable" }} engine="codex-local"
saving={false} disabled={false} session={null} turns={[]} onModel={vi.fn()} /></OwbI18nProvider>);
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Employee model" }));
expect(await screen.findByRole("note")).toHaveTextContent("Refreshing the list does not verify access");
expect(screen.getByRole("status")).toHaveTextContent("Model list is unavailable");
});

it.each([
["subsequent success", [rejected(), rejected({ id: "success", status: "completed", errorCode: undefined, error: undefined })]],
["another login mode", [rejected({ engine: "codex" })]],
["another model", [rejected({ model: "another-model" })]],
] as const)("does not reuse a stale model rejection after %s", async (_name, turns) => {
render(<ConversationOptions config={config} engine="codex-local" saving={false} disabled={false} session={null} turns={[...turns]} onModel={vi.fn()} />);
fireEvent.mouseDown(screen.getByRole("combobox", { name: "员工模型" }));
expect(await screen.findByRole("note")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

it("keeps other Agents and ordinary failures on their existing recovery path", async () => {
render(<><ConversationOptions config={config} engine="qoder" saving={false} disabled={false} session={null} turns={[rejected()]}
onModel={vi.fn()} /><TurnThread turns={[rejected({ errorCode: "codex.turn_failed", error: "Invalid input schema" })]} /></>);
const alert = screen.getByRole("alert");
expect(within(alert).getByText("Invalid input schema")).toBeInTheDocument();
expect(alert).toHaveTextContent("调整任务");
fireEvent.mouseDown(screen.getByRole("combobox", { name: "员工模型" }));
expect(await screen.findByText("Another model", { selector: ".owb-model-choice > span" })).toBeInTheDocument();
expect(screen.queryByRole("note")).not.toBeInTheDocument();
});
6 changes: 6 additions & 0 deletions apps/server/bin/qoder-engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1868,6 +1868,12 @@ function turnRunCodex(workspaceDir, positionId, input, engineModel = "codex") {
code = "codex.authentication_failed";
message = "Codex 登录验证失败(401)。请在当前运行环境重新登录 Codex 后再试。";
retryable = false;
} else if (/\bmodel is not supported when using Codex with a ChatGPT account\b/i.test(message)) {
// Catalog discovery is not an inference entitlement check. Keep this
// specific rejection distinct from malformed requests or login failures.
code = "codex.model_access_denied";
message = "The selected model was rejected for this Codex ChatGPT login. Choose another model or check the login in the same Codex CLI before resending. Refreshing the model list does not verify access.";
retryable = false;
}
emit({
type: "run.failed",
Expand Down
53 changes: 53 additions & 0 deletions apps/server/test/qoder-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2010,6 +2010,59 @@ test("codex turn run: a provider reconnect loop fails with the provider message
assert.equal(result.code, 0);
});

for (const engine of ["codex", "codex-local"] as const) {
for (const jsonEnvelope of [false, true]) {
test(`Codex model access rejection is actionable without fallback (${engine}, JSON=${jsonEnvelope})`, { skip: codexSkip }, async t => {
const dir = await makeWorkspace(), fixture = await fs.mkdtemp(path.join(os.tmpdir(), "roleweave-codex-model-access-"));
t.after(() => Promise.all([fs.rm(dir, { force: true, recursive: true }), fs.rm(fixture, { force: true, recursive: true })]));
const message = "The 'gpt-6-luna' model is not supported when using Codex with a ChatGPT account.";
const providerError = jsonEnvelope ? JSON.stringify({ type: "error", status: 400, error: { type: "invalid_request_error", message } }) : message;
const argsFile = path.join(fixture, "args.json");
const fake = await writeFakeCodex(fixture, `#!/usr/bin/env node
require('node:fs').appendFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2)) + "\\n");
console.log(JSON.stringify({ type: 'error', message: ${JSON.stringify(providerError)} }));
console.log(JSON.stringify({ type: 'turn.failed', error: { message: ${JSON.stringify(providerError)} } }));`);
const result = await runAdapter(["turn", "run", dir, "--position", "engineer", "--stdin"], {
stdin: JSON.stringify({ input: "Where is the workspace?" }),
env: { DIGITAL_EMPLOYEE_ENGINE_MODEL: engine, DIGITAL_EMPLOYEE_CODEX_COMMAND: fake, ROLEWEAVE_TURN_MODEL: "gpt-6-luna" },
});
const events = codexEvents(result.stdout);
assert.equal(result.code, 0);
assert.equal(events.filter(event => event.type === "run.failed").length, 1);
assert.equal(events.some(event => event.type === "run.completed" || event.type === "model.delta"), false);
const error = events.at(-1)?.error as Record<string, unknown>;
assert.equal(error.code, "codex.model_access_denied");
assert.equal(error.retryable, false);
assert.match(String(error.message), /ChatGPT login/);
assert.match(String(error.message), /Choose another model/);
assert.match(String(error.message), /does not verify access/);
assert.doesNotMatch(String(error.message), /invalid_request_error|"status"|gpt-6-luna/);
const invocations = (await fs.readFile(argsFile, "utf8")).trim().split("\n");
assert.equal(invocations.length, 1, "must not probe or retry with a substituted model");
const args = JSON.parse(invocations[0]!) as string[];
assert.equal(args[args.indexOf("--model") + 1], "gpt-6-luna");
});
}
}

for (const [message, code] of [
['{"status":400,"error":{"type":"invalid_request_error","message":"Invalid input schema"}}', "codex.turn_failed"],
["unexpected status 401 Unauthorized", "codex.authentication_failed"],
["unexpected status 403 Forbidden", "codex.access_denied"],
["Stream disconnected before completion", "codex.turn_failed"],
]) {
test(`Codex does not classify unrelated errors as model access: ${code} (${message})`, { skip: codexSkip }, async t => {
const dir = await makeWorkspace(), fixture = await fs.mkdtemp(path.join(os.tmpdir(), "roleweave-codex-model-control-"));
t.after(() => Promise.all([fs.rm(dir, { force: true, recursive: true }), fs.rm(fixture, { force: true, recursive: true })]));
const fake = await writeFakeCodex(fixture, `#!/usr/bin/env node
console.log(JSON.stringify({type: 'turn.failed', error: { message: ${JSON.stringify(message)} }}));`);
const result = await runAdapter(["turn", "run", dir, "--position", "engineer", "--stdin"], {
stdin: '{"input":"run"}', env: { DIGITAL_EMPLOYEE_ENGINE_MODEL: "codex-local", DIGITAL_EMPLOYEE_CODEX_COMMAND: fake },
});
assert.equal((codexEvents(result.stdout).at(-1)?.error as Record<string, unknown>).code, code);
});
}

test("Codex access-denied HTML is an actionable connection failure and never task output", { skip: codexSkip }, async t => {
const dir = await makeWorkspace(), fixture = await fs.mkdtemp(path.join(os.tmpdir(), "roleweave-codex-403-"));
t.after(() => Promise.all([fs.rm(dir, { force: true, recursive: true }), fs.rm(fixture, { force: true, recursive: true })]));
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ export const enCatalog: Record<string, string> = {
"model.catalogRefresh": "Refresh model list",
"model.catalog.stale": "Unable to refresh. Showing the previous model list.",
"model.catalog.unavailable": "Model list is unavailable. Refresh to try again.",
"model.codexAccessUnverified": "Listed and recommended models are not verified for this Codex login. Refreshing the list does not verify access.",
"model.codexLastRejected": "The last request for this model was rejected for the Codex login. Choose another model or check the CLI login before resending.",
"model.recommended": "Recommended",
"model.agentDefault": "Agent default",
"model.loading": "Loading models…",
Expand Down Expand Up @@ -583,6 +585,9 @@ export const enCatalog: Record<string, string> = {
"turn.timeoutPreserved": "The task stopped after reaching its time limit. Received content was preserved; review it before running again.",
"turn.failedTitle": "Execution did not finish",
"turn.failedHelp": "Check the Agent connection or adjust the task, then run it again.",
"turn.modelAccessDeniedTitle": "Model access denied",
"turn.codexModelAccessDenied": "Codex rejected {model} for the ChatGPT login used by this request.",
"turn.codexModelAccessHelp": "Choose another employee model or check the login in the same Codex CLI, then resend. Refreshing the list does not verify access.",
"turn.retry": "Run again",
"turn.toolsExecuted": "Ran {count} tools",
"turn.toolsExecutedWithFailures": "Ran {count} tools, {failedCount} failed",
Expand Down
Loading
Loading