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
86 changes: 72 additions & 14 deletions lib/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1741,13 +1741,37 @@ export class AccountManager {
}
}

/**
* Name of the account's currently-selected workspace, if any. Lets same-email
* accounts that live in different workspaces (personal Plus vs business/team)
* stay distinguishable in `list`/`status` output. See issue #491.
*/
function activeWorkspaceName(
account:
| { workspaces?: Workspace[]; currentWorkspaceIndex?: number }
| undefined,
): string | undefined {
const workspaces = account?.workspaces;
if (!workspaces || workspaces.length === 0) return undefined;
const idx = account?.currentWorkspaceIndex ?? 0;
const workspace = workspaces[idx] ?? workspaces[0];
return workspace?.name?.trim() || undefined;
}

export function formatAccountLabel(
account:
| { email?: string; accountId?: string; accountLabel?: string }
| {
email?: string;
accountId?: string;
accountLabel?: string;
workspaces?: Workspace[];
currentWorkspaceIndex?: number;
}
| undefined,
index: number,
): string {
const accountLabel = account?.accountLabel?.trim();
const workspaceName = activeWorkspaceName(account);
const email = account?.email?.trim();
const accountId = account?.accountId?.trim();
const idSuffix = accountId
Expand All @@ -1756,19 +1780,53 @@ export function formatAccountLabel(
: accountId
: null;

if (accountLabel && email && idSuffix) {
return `Account ${index + 1} (${accountLabel}, ${email}, id:${idSuffix})`;
}
if (accountLabel && email)
return `Account ${index + 1} (${accountLabel}, ${email})`;
if (accountLabel && idSuffix)
return `Account ${index + 1} (${accountLabel}, id:${idSuffix})`;
if (accountLabel) return `Account ${index + 1} (${accountLabel})`;
if (email && idSuffix)
return `Account ${index + 1} (${email}, id:${idSuffix})`;
if (email) return `Account ${index + 1} (${email})`;
if (idSuffix) return `Account ${index + 1} (${idSuffix})`;
return `Account ${index + 1}`;
const segments: string[] = [];
if (accountLabel) segments.push(accountLabel);
// Surface the active workspace so two same-email accounts in different
// workspaces remain distinguishable; skip it when it would just repeat the
// manual account label.
if (workspaceName && workspaceName !== accountLabel) {
segments.push(`[${workspaceName}]`);
}
if (email) segments.push(email);
// A bare id stands alone (e.g. "Account 1 (123456)"); once any other
// segment precedes it, prefix with "id:" for clarity.
if (idSuffix) {
segments.push(segments.length > 0 ? `id:${idSuffix}` : idSuffix);
}

if (segments.length === 0) return `Account ${index + 1}`;
return `Account ${index + 1} (${segments.join(", ")})`;
}

/**
* One display line per workspace tracked on an account, with the active one
* marked. Lets `status`/`list` and the `workspace` command show every workspace
* a same-email account can rotate between (issue #491). Callers decide when to
* render these (e.g. only when more than one workspace exists) and supply the
* leading indent.
*/
export function formatWorkspaceLines(
account:
| { workspaces?: Workspace[]; currentWorkspaceIndex?: number }
| undefined,
indent = " ",
): string[] {
const workspaces = account?.workspaces;
if (!workspaces || workspaces.length === 0) return [];
const activeIndex = account?.currentWorkspaceIndex ?? 0;
return workspaces.map((workspace, idx) => {
const isActive = idx === activeIndex;
const name = workspace.name?.trim() || "(unnamed)";
const id = workspace.id?.trim() ?? "";
const idSuffix = id.length > 6 ? id.slice(-6) : id;
const tags: string[] = [];
if (isActive) tags.push("active");
if (workspace.enabled === false) tags.push("disabled");
const tagLabel = tags.length > 0 ? ` (${tags.join(", ")})` : "";
const idLabel = idSuffix ? ` id:${idSuffix}` : "";
return `${indent}${isActive ? "*" : "-"} ${idx + 1}. [${name}]${idLabel}${tagLabel}`;
});
}

export function formatCooldown(
Expand Down
41 changes: 40 additions & 1 deletion lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,13 @@ import {
import { loadPersistedRuntimeObservabilitySnapshot } from "./runtime/runtime-observability.js";
import { runSwitchCommand } from "./codex-manager/commands/switch.js";
import { runUnpinCommand } from "./codex-manager/commands/unpin.js";
import { runWorkspaceCommand } from "./codex-manager/commands/workspace.js";
import { runUsageCommand } from "./codex-manager/commands/usage.js";
import { parseAuthLoginArgs, printUsage } from "./codex-manager/help.js";
import {
type AuthLoginOptions,
parseAuthLoginArgs,
printUsage,
} from "./codex-manager/help.js";
import {
applyUiThemeFromDashboardSettings,
configureUnifiedSettings,
Expand Down Expand Up @@ -202,6 +207,7 @@ const ACCOUNT_MANAGER_COMMANDS = new Set([
"status",
"switch",
"unpin",
"workspace",
"best",
"check",
"features",
Expand Down Expand Up @@ -2782,6 +2788,32 @@ async function runAuthLogin(args: string[]): Promise<number> {
}

const loginOptions = parsedArgs.options;
// `--org <id>` binds this login to a specific workspace/org so the same
// email's personal vs business/team workspace can be registered on demand
// (issue #491). It reuses the CODEX_AUTH_ACCOUNT_ID override that every login
// resolver already honors. Scope it to this invocation and restore the prior
// value in a finally so a later login in the same process (menu re-entry, a
// reused test worker) is never silently bound to a stale org.
if (!loginOptions.org) {
return runAuthLoginFlow(loginOptions);
}
const previousAccountIdOverride = process.env.CODEX_AUTH_ACCOUNT_ID;
process.env.CODEX_AUTH_ACCOUNT_ID = loginOptions.org;
console.log(`Binding this login to workspace org id: ${loginOptions.org}`);
try {
return await runAuthLoginFlow(loginOptions);
} finally {
if (previousAccountIdOverride === undefined) {
delete process.env.CODEX_AUTH_ACCOUNT_ID;
} else {
process.env.CODEX_AUTH_ACCOUNT_ID = previousAccountIdOverride;
}
}
}

async function runAuthLoginFlow(
loginOptions: AuthLoginOptions,
): Promise<number> {
setStoragePath(null);
let pendingMenuQuotaRefresh: Promise<void> | null = null;
let menuQuotaRefreshStatus: string | undefined;
Expand Down Expand Up @@ -3558,6 +3590,13 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise<number> {
getStoragePath,
});
}
if (command === "workspace") {
return runWorkspaceCommand(rest, {
setStoragePath,
loadAccounts,
saveAccounts,
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (command === "check") {
return runCheckCommand({ runHealthCheck });
}
Expand Down
9 changes: 9 additions & 0 deletions lib/codex-manager/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
formatAccountLabel,
formatCooldown,
formatWaitTime,
formatWorkspaceLines,
} from "../../accounts.js";
import {
evaluateForecastAccounts,
Expand Down Expand Up @@ -242,6 +243,14 @@ export async function runStatusCommand(
if (primaryReason) {
logInfo(` reason: ${primaryReason}`);
}
// Surface every workspace a same-email account can rotate between, so
// personal Plus vs business/team stay visible at once (issue #491).
if ((account.workspaces?.length ?? 0) > 1) {
logInfo(" workspaces:");
for (const workspaceLine of formatWorkspaceLines(account, " ")) {
logInfo(workspaceLine);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return 0;
Expand Down
133 changes: 133 additions & 0 deletions lib/codex-manager/commands/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
formatAccountLabel,
formatWorkspaceLines,
} from "../../accounts.js";
import type { AccountStorageV3 } from "../../storage.js";
import { saveAccountsWithRetry } from "../forecast-report-shared.js";

type LoadedStorage = AccountStorageV3 | null;

export interface WorkspaceCommandDeps {
setStoragePath: (path: string | null) => void;
loadAccounts: () => Promise<LoadedStorage>;
saveAccounts: (storage: AccountStorageV3) => Promise<void>;
logError?: (message: string) => void;
logInfo?: (message: string) => void;
}

/**
* `codex-multi-auth workspace <account> [workspace]`
*
* With only an account index, lists the workspaces that account can rotate
* between (personal Plus vs business/team under one email, issue #491). With a
* workspace index too, sets it as the active workspace for that account.
*/
export async function runWorkspaceCommand(
args: string[],
deps: WorkspaceCommandDeps,
): Promise<number> {
deps.setStoragePath(null);
const logError = deps.logError ?? console.error;
const logInfo = deps.logInfo ?? console.log;

const storage = await deps.loadAccounts();
if (!storage || storage.accounts.length === 0) {
logError("No accounts configured.");
return 1;
}

const accountArg = args[0];
if (!accountArg) {
logError(
"Missing account index. Usage: codex-multi-auth workspace <account> [workspace]",
);
return 1;
}

const parsedAccount = Number.parseInt(accountArg, 10);
if (!Number.isFinite(parsedAccount) || parsedAccount < 1) {
logError(`Invalid account index: ${accountArg}`);
return 1;
}

const accountIndex = parsedAccount - 1;
if (accountIndex >= storage.accounts.length) {
logError(
`Account index out of range. Valid range: 1-${storage.accounts.length}`,
);
return 1;
}

const account = storage.accounts[accountIndex];
if (!account) {
logError(`Account ${parsedAccount} not found.`);
return 1;
}

const workspaces = account.workspaces ?? [];
if (workspaces.length === 0) {
logInfo(
`Account ${parsedAccount} (${formatAccountLabel(account, accountIndex)}) has no tracked workspaces.`,
);
return 0;
}

const workspaceArg = args[1];
if (!workspaceArg) {
logInfo(`Account ${parsedAccount}: ${formatAccountLabel(account, accountIndex)}`);
for (const line of formatWorkspaceLines(account, " ")) {
logInfo(line);
}
logInfo("");
logInfo(
`Switch with: codex-multi-auth workspace ${parsedAccount} <workspace-number>`,
);
return 0;
}

const parsedWorkspace = Number.parseInt(workspaceArg, 10);
if (
!Number.isFinite(parsedWorkspace) ||
parsedWorkspace < 1 ||
parsedWorkspace > workspaces.length
) {
logError(
`Invalid workspace index. Valid range: 1-${workspaces.length}`,
);
return 1;
}

const workspaceIndex = parsedWorkspace - 1;
const target = workspaces[workspaceIndex];
if (!target) {
logError(`Workspace ${parsedWorkspace} not found.`);
return 1;
}

const targetName = target.name?.trim() || "(unnamed)";
if (target.enabled === false) {
logError(
`Workspace ${parsedWorkspace} ([${targetName}]) is disabled and cannot be selected.`,
);
return 1;
}

if (account.currentWorkspaceIndex === workspaceIndex) {
logInfo(
`Account ${parsedAccount} is already using workspace ${parsedWorkspace}: [${targetName}].`,
);
return 0;
}

account.currentWorkspaceIndex = workspaceIndex;
await saveAccountsWithRetry(storage, deps.saveAccounts);
Comment on lines +122 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 concurrent write race (TOCTOU)

this follows the same load-mutate-save pattern as switch/unpin, but worth calling out explicitly: the runtime-rotation-proxy writes to the same accounts file (e.g. disableCurrentWorkspace, lastUsed updates). if the proxy writes between loadAccounts() and saveAccountsWithRetry() here, this save silently overwrites those changes — most critically, a workspace enabled: false written by the proxy could be restored to true. no file-level lock or re-read-before-write guards against this. low probability in practice, but concurrency tests for this path are absent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/codex-manager/commands/workspace.ts
Line: 122-123

Comment:
**concurrent write race (TOCTOU)**

this follows the same load-mutate-save pattern as `switch`/`unpin`, but worth calling out explicitly: the runtime-rotation-proxy writes to the same accounts file (e.g. `disableCurrentWorkspace`, `lastUsed` updates). if the proxy writes between `loadAccounts()` and `saveAccountsWithRetry()` here, this save silently overwrites those changes — most critically, a workspace `enabled: false` written by the proxy could be restored to `true`. no file-level lock or re-read-before-write guards against this. low probability in practice, but concurrency tests for this path are absent.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex


// Guard a possibly-undefined id the same way formatWorkspaceLines does, in
// case on-disk data does not conform to the Workspace interface.
const id = target.id?.trim() ?? "";
const idSuffix = id.length > 6 ? id.slice(-6) : id;
logInfo(
`Account ${parsedAccount} now using workspace ${parsedWorkspace}: [${targetName}]${idSuffix ? ` (id:${idSuffix})` : ""}.`,
);
return 0;
}
30 changes: 28 additions & 2 deletions lib/codex-manager/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ export function printUsage(): void {
"Codex Multi-Auth CLI",
"",
"Start here:",
" codex-multi-auth login [--device-auth|--manual|--no-browser]",
" codex-multi-auth login [--device-auth|--manual|--no-browser] [--org <org_id>]",
" codex-multi-auth status",
" codex-multi-auth check",
"",
"Daily use:",
" codex-multi-auth list",
" codex-multi-auth switch <index> (pins the account for runtime routing)",
" codex-multi-auth unpin (clears the manual pin set by switch)",
" codex-multi-auth workspace <account> [workspace] (list or switch an account's workspaces)",
" codex-multi-auth best [--live] [--json] [--model <model>] (clears any manual pin set by switch)",
" codex-multi-auth forecast [--live] [--json] [--model <model>]",
" codex-multi-auth account tag|untag|weight|pause|unpause|drain|undrain|note ...",
Expand Down Expand Up @@ -51,6 +52,7 @@ export function printUsage(): void {
export type AuthLoginOptions = {
manual: boolean;
deviceAuth: boolean;
org?: string;
};

export type ParsedAuthLoginArgs =
Expand All @@ -65,7 +67,8 @@ export function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs {
};
const manualFlags: string[] = [];

for (const arg of args) {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--manual" || arg === "--no-browser") {
options.manual = true;
if (!manualFlags.includes(arg)) {
Expand All @@ -77,6 +80,29 @@ export function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs {
options.deviceAuth = true;
continue;
}
if (arg === "--org" || arg?.startsWith("--org=")) {
// Bind this login to a specific workspace/org id (issue #491). Reuses
// the CODEX_AUTH_ACCOUNT_ID override mechanism so the same email's
// personal vs business/team workspace can be registered on demand.
let value: string | undefined;
if (arg === "--org") {
value = args[i + 1];
i += 1;
} else {
value = arg.slice("--org=".length);
}
const trimmed = value?.trim();
if (!trimmed || trimmed.startsWith("--")) {
return {
ok: false,
reason: "error",
message:
"Missing value for --org. Usage: codex-multi-auth login --org <org_id>",
};
}
options.org = trimmed;
continue;
}
if (arg === "--help" || arg === "-h") {
return { ok: false, reason: "help" };
}
Expand Down
Loading