Skip to content
Closed
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
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,31 @@ jobs:
- name: Build
run: npm run build

- name: Upload build artifact
if: matrix.node-version == '20.x'
uses: actions/upload-artifact@v4
with:
name: dist-node20
path: dist/

- name: Run tests
run: npm test

coverage-gate:
name: Coverage Gate
runs-on: ubuntu-latest
needs: test

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: dist-node20
path: dist/

- name: Setup Node.js
uses: actions/setup-node@v4
with:
Expand All @@ -73,7 +87,7 @@ jobs:
run: npm ci

- name: Run tests with coverage threshold gate
run: npm run coverage
run: npm run test:coverage

lint:
name: Lint
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/supply-chain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
schedule:
- cron: "0 4 * * 1"

env:
CODEX_LICENSE_DENYLIST: "GPL-2.0,GPL-3.0,AGPL-3.0"

jobs:
dependency-review:
name: Dependency Review
Expand All @@ -26,7 +29,7 @@ jobs:
with:
fail-on-severity: high
fail-on-scopes: runtime
deny-licenses: GPL-2.0, GPL-3.0, AGPL-3.0
deny-licenses: ${{ env.CODEX_LICENSE_DENYLIST }}

sca-and-license:
name: SCA and License Gate
Expand Down Expand Up @@ -69,7 +72,7 @@ jobs:
run: npm ci

- name: Generate CycloneDX SBOM
run: npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json --omit dev
run: npm run sbom

- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
Expand Down
8 changes: 8 additions & 0 deletions docs/development/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ npm run bench:edit-formats:smoke
7. `npm run license:check`
8. run docs command checks for newly documented command paths

### Upgrade Notes (PR #32)

- Gate ordering was updated so `npm run coverage` runs before `npm run build`.
- Two required supply-chain checks were added to the standard local sequence:
- `npm run audit:ci`
- `npm run license:check`
- If you maintain local CI wrappers or pre-push scripts, update them to use the order above and rerun once to refresh baselines.

* * *

## Auth/Account Change Test Matrix
Expand Down
12 changes: 11 additions & 1 deletion docs/runbooks/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ Routine operations checklist for maintainers.
2. Check recent audit and plugin logs:
- `~/.codex/multi-auth/logs/audit.log`
- `~/.codex/multi-auth/logs/codex-plugin/`
- `%USERPROFILE%\.codex\multi-auth\logs\audit.log` (Windows)
- `%USERPROFILE%\.codex\multi-auth\logs\codex-plugin\` (Windows)
3. Check dead-letter queue growth:
- `~/.codex/multi-auth/background-job-dlq.jsonl`
- `%USERPROFILE%\.codex\multi-auth\background-job-dlq.jsonl` (Windows)

---

Expand All @@ -24,7 +27,8 @@ Routine operations checklist for maintainers.
1. Rotate encryption key material:
- set `CODEX_AUTH_ENCRYPTION_KEY` (new key)
- set `CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY` (prior key)
- run `codex auth rotate-secrets --json`
- run `codex auth rotate-secrets --json --idempotency-key <run-id>`
- prefer a stable run id source (for example `weekly-YYYYMMDD` or CI `${{ github.run_id }}`)
- remove previous key after successful rotation validation
2. Review dependency and license policy reports:
- `npm run audit:ci`
Expand Down Expand Up @@ -73,6 +77,12 @@ Routine operations checklist for maintainers.
2. If writes fail under filesystem lock contention:
- check stale `*.lock` files under runtime root
- inspect DLQ entries for repeated write failures
- quick log triage:
- macOS/Linux: `tail -n 200 ~/.codex/multi-auth/logs/audit.log`
- Windows PowerShell: `Get-Content $env:USERPROFILE\.codex\multi-auth\logs\audit.log -Tail 200`
- quick DLQ triage:
- macOS/Linux: `tail -n 50 ~/.codex/multi-auth/background-job-dlq.jsonl`
- Windows PowerShell: `Get-Content $env:USERPROFILE\.codex\multi-auth\background-job-dlq.jsonl -Tail 50`
3. If CI secret scan fails:
- rotate exposed secret
- invalidate related tokens
Expand Down
24 changes: 19 additions & 5 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
return sanitized;
};

const toSafeAuditResource = (rawUrl: unknown): string => {
try {
const parsed = new URL(String(rawUrl));
return `${parsed.origin}${parsed.pathname}`;
} catch {
const sanitized = String(rawUrl).split(/[?#]/, 1)[0];
return sanitized && sanitized.length > 0 ? sanitized : "unknown";
}
};

type RuntimeMetrics = {
startedAt: number;
totalRequests: number;
Expand Down Expand Up @@ -1684,13 +1694,14 @@ while (attempted.size < Math.max(1, accountCount)) {
} else if (abortSignal && onUserAbort) {
abortSignal.addEventListener("abort", onUserAbort, { once: true });
}
const safeAuditResource = toSafeAuditResource(url);

try {
runtimeMetrics.totalRequests++;
auditLog(
AuditAction.REQUEST_START,
account.email ?? `account-${account.index + 1}`,
url,
safeAuditResource,
AuditOutcome.SUCCESS,
{
model,
Expand Down Expand Up @@ -1722,16 +1733,18 @@ while (attempted.size < Math.max(1, accountCount)) {
);
}
const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
const networkErrorType =
networkError instanceof Error ? networkError.name : "unknown_error";
logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`);
auditLog(
AuditAction.REQUEST_FAILURE,
account.email ?? `account-${account.index + 1}`,
url,
safeAuditResource,
AuditOutcome.FAILURE,
{
model,
accountIndex: account.index + 1,
error: errorMsg,
errorType: networkErrorType,
stage: "network",
},
);
Expand Down Expand Up @@ -2399,7 +2412,7 @@ while (attempted.size < Math.max(1, accountCount)) {
auditLog(
AuditAction.REQUEST_SUCCESS,
successAccountForResponse.email ?? `account-${successAccountForResponse.index + 1}`,
url,
safeAuditResource,
AuditOutcome.SUCCESS,
{
model,
Expand Down Expand Up @@ -2450,10 +2463,11 @@ while (attempted.size < Math.max(1, accountCount)) {
: `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`;
runtimeMetrics.failedRequests++;
runtimeMetrics.lastError = message;
const safePluginAuditResource = toSafeAuditResource(url);
auditLog(
AuditAction.REQUEST_FAILURE,
"plugin",
url,
safePluginAuditResource,
AuditOutcome.FAILURE,
{
model,
Expand Down
25 changes: 20 additions & 5 deletions lib/background-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,24 @@ function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

function sanitizeErrorMessage(message: string): string {
return message
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "***REDACTED***")
.replace(/\b(?:access|refresh|id)?_?token(?:=|:)?\s*\S+/gi, "token=***REDACTED***")
.replace(/\b(Bearer)\s+\S+/gi, "$1 ***REDACTED***");
}

function getDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
return Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1));
const cappedDelay = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1));
const jitterFactor = 1 + (Math.random() * 0.4 - 0.2);
return Math.max(1, Math.round(cappedDelay * jitterFactor));
}

function isRetryableByDefault(error: unknown): boolean {
const statusCode = (error as { statusCode?: unknown } | undefined)?.statusCode;
if (typeof statusCode === "number" && statusCode === 429) {
return true;
}
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (typeof code !== "string") return false;
return code === "EBUSY" || code === "EPERM" || code === "EAGAIN" || code === "ETIMEDOUT";
Expand Down Expand Up @@ -75,7 +88,9 @@ export async function runBackgroundJobWithRetry<T>(options: BackgroundJobRetryOp
const retryable = options.retryable ?? isRetryableByDefault;

let lastError: unknown;
let attemptsMade = 0;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
attemptsMade = attempt;
try {
return await options.task();
} catch (error) {
Expand All @@ -87,12 +102,12 @@ export async function runBackgroundJobWithRetry<T>(options: BackgroundJobRetryOp
}
}

const errorMessage = toErrorMessage(lastError);
const errorMessage = sanitizeErrorMessage(toErrorMessage(lastError));
const deadLetter: DeadLetterEntry = {
version: 1,
timestamp: new Date().toISOString(),
job: options.name,
attempts: maxAttempts,
attempts: attemptsMade,
error: errorMessage,
...(options.context ? { context: redactForExternalOutput(options.context) } : {}),
};
Expand All @@ -102,13 +117,13 @@ export async function runBackgroundJobWithRetry<T>(options: BackgroundJobRetryOp
} catch (dlqError) {
logWarn("Failed to append background job dead-letter", {
job: options.name,
error: toErrorMessage(dlqError),
error: sanitizeErrorMessage(toErrorMessage(dlqError)),
});
}

logWarn("Background job failed after retries", {
job: options.name,
attempts: maxAttempts,
attempts: attemptsMade,
error: errorMessage,
});
throw (lastError instanceof Error ? lastError : new Error(errorMessage));
Expand Down
55 changes: 49 additions & 6 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,26 @@ function maybeRedactJsonOutput<T>(value: T): T {
return shouldRedact ? redactForExternalOutput(value) : value;
}

function looksLikeOptionToken(value: string): boolean {
const trimmed = value.trim();
if (trimmed.length === 0) return false;
return /^-{1,2}[A-Za-z0-9]/.test(trimmed);
}

function hasOptionFlag(args: readonly string[], flag: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const next = args[index + 1];
return typeof next === "string" && next.trim().length > 0;
return (
typeof next === "string" &&
next.trim().length > 0 &&
!looksLikeOptionToken(next)
);
}
if (arg?.startsWith(`${flag}=`)) {
const value = arg.slice(flag.length + 1).trim();
return value.length > 0;
return value.length > 0 && !looksLikeOptionToken(value);
}
}
return false;
Expand Down Expand Up @@ -249,6 +259,31 @@ function normalizeFailureDetail(
return bounded.length > 0 ? bounded : "refresh failed";
}

function redactFreeformSecretText(message: string): string {
return message
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "***REDACTED***")
.replace(
/\b(?:access|refresh|id)?_?token(?:=|:)?\s*([A-Z0-9._~+/=-]+)/gi,
"token=***REDACTED***",
)
.replace(/\b(Bearer)\s+[A-Z0-9._~+/=-]+\b/gi, "$1 ***REDACTED***");
}

function sanitizeCliErrorMessage(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
const redacted = redactForExternalOutput(raw);
if (typeof redacted === "string") {
const normalized = collapseWhitespace(redactFreeformSecretText(redacted));
return normalized.length > 0 ? normalized : "unknown error";
}
try {
const normalized = collapseWhitespace(redactFreeformSecretText(JSON.stringify(redacted)));
return normalized.length > 0 ? normalized : "unknown error";
} catch {
return "unknown error";
}
}

function joinStyledSegments(parts: string[]): string {
if (parts.length === 0) return "";
const separator = stylePromptText(" | ", "muted");
Expand Down Expand Up @@ -1518,6 +1553,7 @@ function encodePaginationCursor(index: number): string {
function decodePaginationCursor(cursor: string): number | null {
try {
const decoded = Buffer.from(cursor, "base64").toString("utf8");
if (!/^\d+$/.test(decoded)) return null;
const parsed = Number.parseInt(decoded, 10);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return parsed;
Expand Down Expand Up @@ -1964,7 +2000,11 @@ function parseListArgs(args: string[]): ParsedArgsResult<ListCliOptions> {
if (!value) {
return { ok: false, message: "Missing value for --page-size" };
}
const parsed = Number.parseInt(value, 10);
const normalized = value.trim();
if (!/^\d+$/.test(normalized)) {
return { ok: false, message: "--page-size must be between 1 and 200" };
}
const parsed = Number.parseInt(normalized, 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 200) {
return { ok: false, message: "--page-size must be between 1 and 200" };
}
Expand All @@ -1974,6 +2014,9 @@ function parseListArgs(args: string[]): ParsedArgsResult<ListCliOptions> {
}
if (arg.startsWith("--page-size=")) {
const value = arg.slice("--page-size=".length).trim();
if (!/^\d+$/.test(value)) {
return { ok: false, message: "--page-size must be between 1 and 200" };
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 200) {
return { ok: false, message: "--page-size must be between 1 and 200" };
Expand Down Expand Up @@ -2177,7 +2220,7 @@ function parseRotateSecretsArgs(args: string[]): ParsedArgsResult<RotateSecretsC
}
if (arg === "--idempotency-key") {
const value = args[i + 1];
if (!value || value.trim().length === 0) {
if (!value || value.trim().length === 0 || looksLikeOptionToken(value)) {
return { ok: false, message: "Missing value for --idempotency-key" };
}
options.idempotencyKey = value.trim();
Expand All @@ -2186,7 +2229,7 @@ function parseRotateSecretsArgs(args: string[]): ParsedArgsResult<RotateSecretsC
}
if (arg.startsWith("--idempotency-key=")) {
const value = arg.slice("--idempotency-key=".length).trim();
if (value.length === 0) {
if (value.length === 0 || looksLikeOptionToken(value)) {
return { ok: false, message: "Missing value for --idempotency-key" };
}
options.idempotencyKey = value;
Expand Down Expand Up @@ -4080,7 +4123,7 @@ async function runRotateSecrets(args: string[]): Promise<number> {
);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = sanitizeCliErrorMessage(error);
emitAudit(AuditAction.CONFIG_CHANGE, AuditOutcome.FAILURE, "rotate-secrets", {
error: message,
...(idempotencyKey ? { idempotencyKey } : {}),
Expand Down
Loading