refactor: make OpenAI Codex legacy doctor-only (#88605)

This commit is contained in:
Peter Steinberger
2026-05-31 12:58:01 +01:00
committed by GitHub
parent 5976f14832
commit 00d17e9df7
29 changed files with 648 additions and 301 deletions

View File

@@ -19,7 +19,7 @@ or validating a change without wasting hours.
Prove the touched surface first. Do not reflexively run the whole suite.
1. Inspect the diff and classify the touched surface:
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed`
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed` (delegates to Crabbox/Testbox)
- normal source checkout, tests only: `pnpm test:changed`
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
@@ -27,7 +27,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
use the Crabbox wrapper with the provider that matches the proof surface.
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
--provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed`. For direct AWS
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
@@ -66,7 +66,7 @@ scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
```bash
pnpm changed:lanes --json
pnpm check:changed # changed typecheck/lint/guards; no Vitest
pnpm check:changed # Crabbox/Testbox changed typecheck/lint/guards; no Vitest
pnpm test:changed # cheap smart changed Vitest targets
pnpm verify # full check, then full Vitest
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed

View File

@@ -95,8 +95,8 @@ Skills own workflows; root owns hard policy and routing.
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
- Checks in a normal source checkout: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox, not locally.
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).

View File

@@ -18,8 +18,8 @@ title: "Tests"
- `pnpm test:changed`: cheap smart changed test run. It runs precise targets from direct test edits, sibling `*.test.ts` files, explicit source mappings, and the local import graph. Broad/config/package changes are skipped unless they map to precise tests.
- `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed`: explicit broad changed test run. Use it when a test harness/config/package edit should fall back to Vitest's broader changed-test behavior.
- `pnpm changed:lanes`: shows the architectural lanes triggered by the diff against `origin/main`.
- `pnpm check:changed`: runs the smart changed check gate for the diff against `origin/main`. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
- Codex worktrees and linked/sparse checkouts: avoid direct local `pnpm test*`, `pnpm check*`, and `pnpm crabbox:run` unless you have verified pnpm will not reconcile dependencies. For tiny explicit-file proof use `node scripts/run-vitest.mjs <path-or-filter>`; for changed gates or broad proof use `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox.
- `pnpm check:changed`: delegates to Crabbox/Testbox by default outside CI, then runs the smart changed check gate for the diff against `origin/main` inside the remote child. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
- Codex worktrees and linked/sparse checkouts: avoid direct local `pnpm test*`, `pnpm check*`, and `pnpm crabbox:run` unless you have verified pnpm will not reconcile dependencies. For tiny explicit-file proof use `node scripts/run-vitest.mjs <path-or-filter>`; for changed gates or broad proof use `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox.
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs are full-suite proof: they use fixed shard groups, expand to leaf configs for local parallel execution, and print the expected local shard fanout before starting. The extension group always expands to the per-extension shard configs instead of one giant root-project process.
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.

View File

@@ -39,7 +39,6 @@ import type { HermesSource } from "./source.js";
import type { PlannedTargets } from "./targets.js";
const OPENAI_PROVIDER_ID = "openai";
const LEGACY_OPENAI_PROVIDER_ID = ["openai", "codex"].join("-");
const OPENAI_DEFAULT_MODEL = "openai/gpt-5.5";
const HERMES_AUTH_DISPLAY_NAME = "Hermes import";
@@ -143,10 +142,7 @@ function hasLegacyOpenAIOAuthTokenFields(value: unknown, keyHint = ""): boolean
}
const provider = readString(value.provider)?.toLowerCase();
const normalizedKeyHint = keyHint.toLowerCase();
const isOpenAIRecord =
normalizedKeyHint.includes("openai") ||
provider === OPENAI_PROVIDER_ID ||
provider === LEGACY_OPENAI_PROVIDER_ID;
const isOpenAIRecord = normalizedKeyHint.includes("openai") || provider === OPENAI_PROVIDER_ID;
const hasTokenPair =
(readString(value.access) && readString(value.refresh)) ||
(readString(value.access_token) && readString(value.refresh_token));

View File

@@ -183,17 +183,16 @@ describe("Hermes migration file and skill items", () => {
await expectPathMissing(path.join(workspaceDir, "logs", "session.log"));
});
it("reports legacy Hermes auth.json OAuth state as manual reauth work", async () => {
it("reports legacy Hermes OpenAI auth.json OAuth state as manual reauth work", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const legacyOpenAIProvider = ["openai", "codex"].join("-");
await writeFile(
path.join(source, "auth.json"),
JSON.stringify({
providers: {
[legacyOpenAIProvider]: {
openai: {
tokens: {
access_token: "old-access",
refresh_token: "old-refresh",
@@ -201,7 +200,7 @@ describe("Hermes migration file and skill items", () => {
},
},
credential_pool: {
[legacyOpenAIProvider]: [
openai: [
{
access_token: "pool-access",
refresh_token: "pool-refresh",

View File

@@ -18,12 +18,10 @@ const {
(params: { provider: string; agentDir?: string }) => boolean
>(() => false),
listProfilesForProviderMock: vi.fn(
(store: { profiles?: Record<string, { provider?: string }> }, provider: string) => {
const normalize = (raw?: string) => (raw === ["openai", "codex"].join("-") ? "openai" : raw);
return Object.entries(store.profiles ?? {})
.filter(([, profile]) => normalize(profile.provider) === provider)
.map(([profileId]) => profileId);
},
(store: { profiles?: Record<string, { provider?: string }> }, provider: string) =>
Object.entries(store.profiles ?? {})
.filter(([, profile]) => profile.provider === provider)
.map(([profileId]) => profileId),
),
resolveApiKeyForProviderMock: vi.fn(
async (_params?: {
@@ -386,25 +384,6 @@ describe("openai image generation provider", () => {
expect(provider.isConfigured?.({ agentDir: "/tmp/agent" })).toBe(false);
});
it("reports configured before doctor rewrites retired OpenAI auth profiles", () => {
const provider = buildOpenAIImageGenerationProvider();
isProviderApiKeyConfiguredMock.mockReturnValue(false);
ensureAuthProfileStoreMock.mockReturnValue({
version: 1,
profiles: {
"openai:default": {
type: "oauth",
provider: ["openai", "codex"].join("-"),
access: "legacy-chatgpt-access",
refresh: "legacy-chatgpt-refresh",
expires: Date.now() + 60_000,
},
},
});
expect(provider.isConfigured?.({ agentDir: "/tmp/agent" })).toBe(true);
});
it("does not report Codex OAuth image auth as configured for custom OpenAI endpoints", () => {
const provider = buildOpenAIImageGenerationProvider();

View File

@@ -4,6 +4,8 @@ import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200;
const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC";
const DOCS_PATH_RE = /^(?:docs\/|README\.md$|AGENTS\.md$|.*\.mdx?$)/u;
const APP_PATH_RE = /^(?:apps\/|Swabble\/|appcast\.xml$)/u;
@@ -236,18 +238,38 @@ export function listChangedPathsFromGit(params) {
if (!base) {
return [];
}
const rangePaths = runGitNameOnlyDiff([`${base}...${head}`], cwd);
let rangePaths;
let noMergeBase = false;
try {
rangePaths = runGitNameOnlyDiff([`${base}...${head}`], cwd);
} catch (error) {
if (!isGitNoMergeBaseError(error)) {
throw error;
}
noMergeBase = true;
rangePaths = runGitNameOnlyDiff([`${base}..${head}`], cwd);
}
if (params.includeWorktree === false) {
return rangePaths;
}
return [
...new Set([
...rangePaths,
...runGitNameOnlyDiff(["--cached", "--diff-filter=ACMRD"], cwd),
...runGitNameOnlyDiff(["--diff-filter=ACMRD"], cwd),
...runGitLsFiles(["--others", "--exclude-standard"], cwd),
]),
].toSorted((left, right) => left.localeCompare(right));
const worktreePaths = [
...runGitNameOnlyDiff(["--cached", "--diff-filter=ACMRD"], cwd),
...runGitNameOnlyDiff(["--diff-filter=ACMRD"], cwd),
...runGitLsFiles(["--others", "--exclude-standard"], cwd),
];
// Raw Crabbox syncs can have unrelated synthetic refs; prefer the synced
// worktree delta instead of turning that into an accidental whole-repo gate.
if (
noMergeBase &&
process.env[RAW_SYNC_CHANGED_LANES_ENV] === "1" &&
worktreePaths.length > 0 &&
rangePaths.length > IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS
) {
rangePaths = [];
}
return [...new Set([...rangePaths, ...worktreePaths])].toSorted((left, right) =>
left.localeCompare(right),
);
}
function runGitNameOnlyDiff(extraArgs, cwd = process.cwd()) {
@@ -260,6 +282,17 @@ function runGitNameOnlyDiff(extraArgs, cwd = process.cwd()) {
return output.split("\n").map(normalizeChangedPath).filter(Boolean);
}
function isGitNoMergeBaseError(error) {
const text = [
error?.message,
error?.stderr?.toString?.("utf8"),
Array.isArray(error?.output)
? error.output.map((value) => value?.toString?.("utf8")).join("\n")
: "",
].join("\n");
return text.includes("no merge base");
}
function runGitLsFiles(extraArgs, cwd = process.cwd()) {
const output = execFileSync("git", ["ls-files", ...extraArgs], {
cwd,
@@ -270,8 +303,9 @@ function runGitLsFiles(extraArgs, cwd = process.cwd()) {
return output.split("\n").map(normalizeChangedPath).filter(Boolean);
}
export function listStagedChangedPaths() {
export function listStagedChangedPaths(cwd = process.cwd()) {
const output = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMRD"], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
maxBuffer: GIT_OUTPUT_MAX_BUFFER,

View File

@@ -82,7 +82,7 @@ export function shouldSkipAppLintForMissingSwiftlint(options = {}) {
}
export function shouldDelegateChangedCheckToCrabbox(argv = [], env = process.env) {
if (!isTruthyEnvFlag(env.OPENCLAW_TESTBOX)) {
if (isTruthyEnvFlag(env.OPENCLAW_CHECK_CHANGED_REMOTE_CHILD)) {
return false;
}
if (isTruthyEnvFlag(env.CI) || isTruthyEnvFlag(env.GITHUB_ACTIONS)) {
@@ -94,7 +94,8 @@ export function shouldDelegateChangedCheckToCrabbox(argv = [], env = process.env
return true;
}
export function buildChangedCheckCrabboxArgs(argv = []) {
export function buildChangedCheckCrabboxArgs(argv = [], options = {}) {
const delegatedArgv = buildDelegatedChangedCheckArgv(argv, options);
return [
"crabbox:run",
"--",
@@ -114,13 +115,36 @@ export function buildChangedCheckCrabboxArgs(argv = []) {
"240m",
"--timing-json",
"--",
"env",
"OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1",
"OPENCLAW_CHANGED_LANES_RAW_SYNC=1",
"CI=1",
"corepack",
"pnpm",
"check:changed",
...argv,
...delegatedArgv,
];
}
function buildDelegatedChangedCheckArgv(argv, options = {}) {
const args = parseArgs(argv);
if (!args.staged || args.paths.length > 0) {
return argv;
}
const stagedPaths = listStagedChangedPaths(options.cwd);
const next = [];
if (args.timed) {
next.push("--timed");
}
if (stagedPaths.length === 0) {
next.push("--no-changes");
return next;
}
next.push("--base", "HEAD", "--head", "HEAD");
next.push("--", ...stagedPaths);
return next;
}
export function shouldRunShrinkwrapGuard(paths) {
return paths.some((changedPath) => SHRINKWRAP_POLICY_PATH_RE.test(changedPath));
}
@@ -148,9 +172,7 @@ export function createShrinkwrapGuardCommand(paths) {
}
export async function runChangedCheckViaCrabbox(argv = [], env = process.env) {
console.error(
"[check:changed] OPENCLAW_TESTBOX=1 set; delegating to Blacksmith Testbox via `pnpm crabbox:run`.",
);
console.error("[check:changed] delegating to Blacksmith Testbox via `pnpm crabbox:run`.");
return await runManagedCommand({
bin: "pnpm",
args: buildChangedCheckCrabboxArgs(argv),
@@ -477,6 +499,7 @@ function parseArgs(argv) {
staged: false,
dryRun: false,
timed: false,
noChanges: false,
help: false,
paths: [],
};
@@ -489,6 +512,7 @@ function parseArgs(argv) {
booleanFlag("--staged", "staged"),
booleanFlag("--dry-run", "dryRun"),
booleanFlag("--timed", "timed"),
booleanFlag("--no-changes", "noChanges"),
booleanFlag("--help", "help"),
booleanFlag("-h", "help"),
],
@@ -515,6 +539,7 @@ function printUsage() {
" --staged Check staged paths instead of git diff paths",
" --dry-run Print the planned checks without running them",
" --timed Print timing summary",
" --no-changes Treat the changed path set as empty",
" -h, --help Show this help",
"",
].join("\n"),
@@ -534,8 +559,9 @@ if (isDirectRun()) {
} else if (shouldDelegateChangedCheckToCrabbox(argv, process.env)) {
process.exitCode = await runChangedCheckViaCrabbox(argv, process.env);
} else {
const paths =
args.paths.length > 0
const paths = args.noChanges
? []
: args.paths.length > 0
? args.paths
: args.staged
? listStagedChangedPaths()

View File

@@ -140,6 +140,10 @@ const shellControlCommandPrefixes = new Set([
]);
const shellCommandExecutionPrefixes = new Set(["exec"]);
const shellInlineCommandInterpreters = new Set(["bash", "dash", "ksh", "sh", "zsh"]);
const remoteChangedGateEnv = [
"OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1",
"OPENCLAW_CHANGED_LANES_RAW_SYNC=1",
];
const shellInlineCommandOptionsWithNextValue = new Set([
"+O",
"+o",
@@ -1437,6 +1441,82 @@ function remoteGitBootstrapForChangedGate(changedGateBase) {
].join(" ");
}
function injectRemoteChangedGateEnvironment(commandArgs) {
if (commandArgs[0] !== "run" || isWindowsRemoteTarget(commandArgs)) {
return commandArgs;
}
const { start } = runCommandBounds(commandArgs);
if (start < 0) {
return commandArgs;
}
const remoteCommand = commandArgs.slice(start);
if (!isChangedGateCommand(remoteCommand)) {
return commandArgs;
}
const normalizedArgs = [...commandArgs];
const markedRemoteCommand =
hasOption(normalizedArgs, "--shell") && remoteCommand.length === 1
? [markShellChangedGateAsRemoteChild(remoteCommand[0])]
: markDirectChangedGateAsRemoteChild(remoteCommand);
normalizedArgs.splice(start, normalizedArgs.length - start, ...markedRemoteCommand);
return normalizedArgs;
}
function markShellChangedGateAsRemoteChild(command) {
const missingEnv = remoteChangedGateEnv.filter((assignment) => !command.includes(assignment));
if (missingEnv.length === 0) {
return command;
}
return `export ${missingEnv.join(" ")}; ${command}`;
}
function markDirectChangedGateAsRemoteChild(commandArgs) {
const missingEnv = remoteChangedGateEnv.filter((assignment) => !commandArgs.includes(assignment));
if (missingEnv.length === 0) {
return commandArgs;
}
const markedCommandArgs = [...commandArgs];
if (shellWordBasename(markedCommandArgs[0]) !== "env") {
return ["env", ...missingEnv, ...markedCommandArgs];
}
markedCommandArgs.splice(envAssignmentInsertIndex(markedCommandArgs), 0, ...missingEnv);
return markedCommandArgs;
}
function envAssignmentInsertIndex(words) {
let index = 1;
for (;;) {
const word = words[index] ?? "";
if (!word) {
return 1;
}
if (word === "--") {
return index + 1;
}
if (word === "-S" || word === "--split-string" || (word.startsWith("-S") && word !== "-S")) {
return index;
}
if (word === "-u" || word === "--unset" || word === "-C" || word === "--chdir") {
index += 2;
continue;
}
if (word.startsWith("--unset=") || word.startsWith("--chdir=")) {
index += 1;
continue;
}
if (word.startsWith("-") && word !== "-") {
index += 1;
continue;
}
return index;
}
}
function isWindowsRemoteTarget(commandArgs) {
return (
optionValue(commandArgs, "--target") === "windows" || hasOption(commandArgs, "--windows-mode")
@@ -2024,11 +2104,12 @@ if (
);
}
const remoteMarkedArgs = injectRemoteChangedGateEnvironment(normalizedArgs);
const childArgs =
childCwd === repoRoot
? injectRemoteAwsMacosJsBootstrap(normalizedArgs, provider)
? injectRemoteAwsMacosJsBootstrap(remoteMarkedArgs, provider)
: injectRemoteChangedGateGitBootstrap(
injectRemoteAwsMacosJsBootstrap(absolutizeLocalRunPaths(normalizedArgs), provider),
injectRemoteAwsMacosJsBootstrap(absolutizeLocalRunPaths(remoteMarkedArgs), provider),
remoteChangedGateBase,
);
const childInvocation = spawnInvocation(binary, childArgs, childEnv, process.platform);

View File

@@ -5,18 +5,17 @@ import {
} from "./oauth-refresh-failure.js";
describe("oauth refresh failure hints", () => {
it("canonicalizes retired OpenAI provider ids in refresh-failure login hints", () => {
const legacyProvider = ["openai", "codex"].join("-");
it("builds OpenAI refresh-failure login hints", () => {
expect(
classifyOAuthRefreshFailure(
`OAuth token refresh failed for ${legacyProvider}: invalid_grant`,
),
classifyOAuthRefreshFailure("OAuth token refresh failed for openai: invalid_grant"),
).toEqual({
provider: "openai",
reason: "invalid_grant",
});
expect(buildOAuthRefreshFailureLoginCommand(legacyProvider)).toBe(
expect(buildOAuthRefreshFailureLoginCommand("openai")).toBe(
"openclaw models auth login --provider openai",
);
expect(buildOAuthRefreshFailureLoginCommand("OpenAI-Codex")).toBe(
"openclaw models auth login --provider openai",
);
});

View File

@@ -11,8 +11,9 @@ export type OAuthRefreshFailureReason =
const OAUTH_REFRESH_FAILURE_PROVIDER_RE = /OAuth token refresh failed for ([^:]+):/i;
const SAFE_PROVIDER_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
const LEGACY_OPENAI_CODEX_PROVIDER_ID = ["openai", "codex"].join("-");
const OPENAI_PROVIDER_ID = "openai";
const RETIRED_REAUTH_PROVIDER_IDS: Readonly<Record<string, string>> = {
"openai-codex": "openai",
};
function isOAuthRefreshFailureMessage(message: string): boolean {
const lower = message.toLowerCase();
@@ -34,10 +35,6 @@ function sanitizeOAuthRefreshFailureProvider(provider: string | null | undefined
return normalized && SAFE_PROVIDER_ID_RE.test(normalized) ? normalized : null;
}
function canonicalizeOAuthRefreshFailureProvider(provider: string | null): string | null {
return provider === LEGACY_OPENAI_CODEX_PROVIDER_ID ? OPENAI_PROVIDER_ID : provider;
}
export function classifyOAuthRefreshFailureReason(
message: string,
): OAuthRefreshFailureReason | null {
@@ -68,18 +65,17 @@ export function classifyOAuthRefreshFailure(message: string): {
return null;
}
return {
provider: canonicalizeOAuthRefreshFailureProvider(
sanitizeOAuthRefreshFailureProvider(extractOAuthRefreshFailureProvider(message)),
),
provider: sanitizeOAuthRefreshFailureProvider(extractOAuthRefreshFailureProvider(message)),
reason: classifyOAuthRefreshFailureReason(message),
};
}
export function buildOAuthRefreshFailureLoginCommand(provider: string | null | undefined): string {
const canonicalProvider = canonicalizeOAuthRefreshFailureProvider(
sanitizeOAuthRefreshFailureProvider(provider),
);
return canonicalProvider
? formatCliCommand(`openclaw models auth login --provider ${canonicalProvider}`)
const sanitizedProvider = sanitizeOAuthRefreshFailureProvider(provider);
const reauthProvider = sanitizedProvider
? (RETIRED_REAUTH_PROVIDER_IDS[sanitizedProvider] ?? sanitizedProvider)
: null;
return reauthProvider
? formatCliCommand(`openclaw models auth login --provider ${reauthProvider}`)
: formatCliCommand("openclaw models auth login");
}

View File

@@ -241,7 +241,7 @@ describe("promoteAuthProfileInOrder", () => {
try {
fs.mkdirSync(agentDir, { recursive: true });
const authPath = resolveAuthStorePath(agentDir);
const legacyProvider = ["openai", "codex"].join("-");
const legacyProvider = "retired-oauth-provider";
fs.writeFileSync(
authPath,
JSON.stringify({
@@ -302,7 +302,7 @@ describe("promoteAuthProfileInOrder", () => {
refresh: "old-refresh-token",
oauthRef: {
source: "openclaw-credentials",
provider: ["openai", "codex"].join("-"),
provider: "retired-oauth-provider",
id: "legacy-profile",
},
},

View File

@@ -10,8 +10,6 @@ import {
import { resolveModelRuntimePolicy } from "./model-runtime-policy.js";
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
const RUNTIME_COMPARISON_PROVIDER_ALIASES = new Map<string, string>([["openai", "openai"]]);
/** True for CLI runtime provider ids such as `claude-cli` and `google-gemini-cli`. */
export function isCliRuntimeProvider(
provider: string,
@@ -57,14 +55,12 @@ function canonicalizeRuntimeAliasProvider(
): string {
const normalized = normalizeProviderId(provider);
return (
RUNTIME_COMPARISON_PROVIDER_ALIASES.get(normalized) ??
listCliRuntimeModelBackendBindings({
config: options.config,
env: options.env,
includeSetupRegistry:
options.includeSetupRegistry ?? (options.config !== undefined || options.env !== undefined),
}).find((binding) => binding.runtime === normalized)?.provider ??
provider
}).find((binding) => binding.runtime === normalized)?.provider ?? provider
);
}

View File

@@ -77,10 +77,6 @@ describe("provider auth aliases", () => {
expect(resolveProviderIdForAuth("openai")).toBe("openai");
});
it("maps retired persisted OpenAI auth provider ids to canonical OpenAI", () => {
expect(resolveProviderIdForAuth(["openai", "codex"].join("-"))).toBe("openai");
});
it("does not reuse aliases across env-resolved plugin roots", () => {
const env = {
HOME: "/home/one",

View File

@@ -25,10 +25,6 @@ type ProviderAuthAliasCandidate = {
target: string;
};
const RETIRED_PROVIDER_AUTH_ALIASES: Readonly<Record<string, string>> = {
[["openai", "codex"].join("-")]: "openai",
};
const PROVIDER_AUTH_ALIAS_ORIGIN_PRIORITY: Readonly<Record<PluginOrigin, number>> = {
config: 0,
bundled: 1,
@@ -207,9 +203,5 @@ export function resolveProviderIdForAuth(
if (!normalized) {
return normalized;
}
return (
resolveProviderAuthAliasMap(params)[normalized] ??
RETIRED_PROVIDER_AUTH_ALIASES[normalized] ??
normalized
);
return resolveProviderAuthAliasMap(params)[normalized] ?? normalized;
}

View File

@@ -583,10 +583,9 @@ describe("repairSessionFileIfNeeded", () => {
expect(JSON.parse(lines[4])).toEqual(deliveryMirror);
});
it("repairs missing tool results in legacy OpenAI ChatGPT transcripts", async () => {
it("repairs missing tool results in legacy OpenAI Codex transcripts", async () => {
const { file } = await createTempSessionPath();
const { header, message } = buildSessionHeaderAndMessage();
const legacyProvider = ["openai", "codex"].join("-");
const toolCallAssistant = {
type: "message",
id: "msg-asst-legacy-process",
@@ -594,9 +593,9 @@ describe("repairSessionFileIfNeeded", () => {
timestamp: new Date().toISOString(),
message: {
role: "assistant",
provider: legacyProvider,
provider: "openai-codex",
model: "gpt-5.5",
api: `${legacyProvider}-responses`,
api: "openai-codex-responses",
content: [{ type: "toolCall", id: "call_process|fc_1", name: "process", arguments: {} }],
stopReason: "toolUse",
},

View File

@@ -206,8 +206,10 @@ function isCodeModeToolCallRepairCandidate(entry: unknown): entry is SessionMess
provider?: unknown;
stopReason?: unknown;
};
const legacyOpenAIProvider = ["openai", "codex"].join("-");
const legacyOpenAIResponsesApi = `${legacyOpenAIProvider}-responses`;
// Persisted transcripts from the retired OpenAI Codex route still need this
// repair so replay sees a complete tool-call/tool-result pair.
const legacyOpenAIProvider = "openai-codex";
const legacyOpenAIResponsesApi = "openai-codex-responses";
const openAIProvider = message.provider === "openai" || message.provider === legacyOpenAIProvider;
const openAIResponsesApi =
message.api === "openai-chatgpt-responses" || message.api === legacyOpenAIResponsesApi;

View File

@@ -59,22 +59,12 @@ describe("auth choice legacy aliases", () => {
]);
});
it("does not keep old OpenAI Codex setup choices alive outside doctor", () => {
const legacyChoice = ["openai", "codex"].join("-");
expect(normalizeLegacyOnboardAuthChoice(legacyChoice, { env: authChoiceManifestEnv() })).toBe(
legacyChoice,
);
it("does not keep retired Codex setup choices alive outside doctor", () => {
expect(normalizeLegacyOnboardAuthChoice("codex-cli", { env: authChoiceManifestEnv() })).toBe(
"codex-cli",
);
expect(
resolveDeprecatedAuthChoiceReplacement(legacyChoice, { env: authChoiceManifestEnv() }),
resolveDeprecatedAuthChoiceReplacement("codex-cli", { env: authChoiceManifestEnv() }),
).toBeUndefined();
expect(normalizeLegacyOnboardAuthChoice(`${legacyChoice}-device-code`)).toBe(
`${legacyChoice}-device-code`,
);
expect(normalizeLegacyOnboardAuthChoice(`${legacyChoice}-api-key`)).toBe(
`${legacyChoice}-api-key`,
);
});
});

View File

@@ -63,6 +63,70 @@ describe("compatibility binding repair migrate", () => {
});
describe("legacy memory search config migrate", () => {
it("moves legacy OpenAI Codex provider config to canonical OpenAI provider config", () => {
const res = migrateLegacyConfigForTest({
models: {
providers: {
"openai-codex": {
baseUrl: "https://chatgpt.com/backend-api/codex",
api: "openai-codex-responses",
models: [
{
id: "gpt-5.5",
name: "GPT-5.5",
api: "openai-codex-responses",
},
],
},
},
},
});
expect(res.config?.models?.providers?.openai).toEqual({
baseUrl: "https://chatgpt.com/backend-api/codex",
api: "openai-chatgpt-responses",
models: [
{
id: "gpt-5.5",
name: "GPT-5.5",
api: "openai-chatgpt-responses",
},
],
});
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
expect(res.changes).toEqual([
'Moved models.providers.openai-codex.api "openai-codex-responses" → "openai-chatgpt-responses".',
'Moved models.providers.openai-codex.models[0].api "openai-codex-responses" → "openai-chatgpt-responses".',
"Moved models.providers.openai-codex → models.providers.openai.",
]);
});
it("records removal when canonical OpenAI provider already exists", () => {
const res = migrateLegacyConfigForTest({
models: {
providers: {
openai: {
api: "openai-chatgpt-responses",
baseUrl: "https://api.openai.com/v1",
},
"openai-codex": {
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
},
},
},
});
expect(res.config?.models?.providers?.openai).toEqual({
api: "openai-chatgpt-responses",
baseUrl: "https://api.openai.com/v1",
});
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
expect(res.changes).toEqual([
"Removed models.providers.openai-codex because models.providers.openai already exists.",
]);
});
it("rewrites top-level legacy auto provider after moving memorySearch into agent defaults", () => {
const raw = {
memorySearch: {

View File

@@ -920,6 +920,99 @@ function rewriteKnownModelRefs(
const RETIRED_MODEL_REF_MESSAGE =
'Configured retired model refs are no longer in the bundled catalogs; run "openclaw doctor --fix" to upgrade them.';
const LEGACY_OPENAI_CODEX_PROVIDER_ID = "openai-codex";
const LEGACY_OPENAI_CODEX_RESPONSES_API = "openai-codex-responses";
const OPENAI_PROVIDER_ID = "openai";
const OPENAI_CHATGPT_RESPONSES_API = "openai-chatgpt-responses";
function hasCanonicalOpenAIProvider(providers: Record<string, unknown>): boolean {
return Object.keys(providers).some(
(providerId) => normalizeProviderId(providerId) === OPENAI_PROVIDER_ID,
);
}
function normalizeLegacyOpenAIResponsesApi(
providerId: string,
provider: Record<string, unknown>,
changes: string[],
): { value: Record<string, unknown>; changed: boolean } {
let changed = false;
const next: Record<string, unknown> = { ...provider };
if (next.api === LEGACY_OPENAI_CODEX_RESPONSES_API) {
next.api = OPENAI_CHATGPT_RESPONSES_API;
changes.push(
`Moved models.providers.${providerId}.api "${LEGACY_OPENAI_CODEX_RESPONSES_API}" → "${OPENAI_CHATGPT_RESPONSES_API}".`,
);
changed = true;
}
if (Array.isArray(provider.models)) {
let modelsChanged = false;
const nextModels = provider.models.map((model, index) => {
const modelRecord = getRecord(model);
if (!modelRecord || modelRecord.api !== LEGACY_OPENAI_CODEX_RESPONSES_API) {
return model;
}
modelsChanged = true;
changes.push(
`Moved models.providers.${providerId}.models[${index}].api "${LEGACY_OPENAI_CODEX_RESPONSES_API}" → "${OPENAI_CHATGPT_RESPONSES_API}".`,
);
return {
...modelRecord,
api: OPENAI_CHATGPT_RESPONSES_API,
};
});
if (modelsChanged) {
next.models = nextModels;
changed = true;
}
}
return { value: next, changed };
}
function migrateLegacyOpenAICodexProvider(raw: Record<string, unknown>, changes: string[]): void {
const models = getRecord(raw.models);
const providers = getRecord(models?.providers);
if (!models || !providers) {
return;
}
let providersChanged = false;
for (const [providerId, providerValue] of Object.entries({ ...providers })) {
const provider = getRecord(providerValue);
if (!provider) {
continue;
}
const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, changes);
if (normalizeProviderId(providerId) !== LEGACY_OPENAI_CODEX_PROVIDER_ID) {
if (normalized.changed) {
providers[providerId] = normalized.value;
providersChanged = true;
}
continue;
}
if (!hasCanonicalOpenAIProvider(providers)) {
providers[OPENAI_PROVIDER_ID] = normalized.value;
changes.push(
`Moved models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} → models.providers.${OPENAI_PROVIDER_ID}.`,
);
} else {
changes.push(
`Removed models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} because models.providers.${OPENAI_PROVIDER_ID} already exists.`,
);
}
delete providers[providerId];
providersChanged = true;
}
if (providersChanged) {
models.providers = providers;
}
}
const RETIRED_MODEL_REF_RULES: LegacyConfigRule[] = [
"agents",
"plugins",
@@ -935,6 +1028,46 @@ const RETIRED_MODEL_REF_RULES: LegacyConfigRule[] = [
}));
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "models.providers.openai-codex->models.providers.openai",
describe: "Move legacy OpenAI Codex provider config to canonical OpenAI provider config",
legacyRules: [
{
path: ["models", "providers"],
message:
'models.providers.openai-codex is legacy; run "openclaw doctor --fix" to move it to models.providers.openai.',
match: (value) => {
const providers = getRecord(value);
return providers
? Object.keys(providers).some(
(providerId) => normalizeProviderId(providerId) === LEGACY_OPENAI_CODEX_PROVIDER_ID,
)
: false;
},
},
{
path: ["models", "providers"],
message:
'openai-codex-responses is legacy; run "openclaw doctor --fix" to use openai-chatgpt-responses.',
match: (value) => {
const providers = getRecord(value);
return providers
? Object.values(providers).some((providerValue) => {
const provider = getRecord(providerValue);
return (
provider?.api === LEGACY_OPENAI_CODEX_RESPONSES_API ||
(Array.isArray(provider?.models) &&
provider.models.some(
(model) => getRecord(model)?.api === LEGACY_OPENAI_CODEX_RESPONSES_API,
))
);
})
: false;
},
},
],
apply: migrateLegacyOpenAICodexProvider,
}),
defineLegacyConfigMigration({
id: "models.retired-model-refs",
describe: "Upgrade retired model refs to current catalog entries",

View File

@@ -88,7 +88,7 @@ describe("modelsAuthListCommand", () => {
mocks.ensureAuthProfileStore.mockReturnValue(store);
const runtime = createRuntime();
await modelsAuthListCommand({ provider: "OpenAI-Codex", agent: "coder", json: true }, runtime);
await modelsAuthListCommand({ provider: "OpenAI", agent: "coder", json: true }, runtime);
expect(mocks.externalCliDiscoveryForProviderAuth).toHaveBeenCalledWith({
cfg: {},
@@ -116,20 +116,10 @@ describe("modelsAuthListCommand", () => {
expect(JSON.stringify(runtime.jsonPayloads[0])).not.toContain("secret");
});
it("treats the OpenAI filter as the friendly view over API-key and Codex subscription profiles", async () => {
const legacyOpenAIProvider = ["openai", "codex"].join("-");
const legacyProfileId = `${legacyOpenAIProvider}:legacy@example.com`;
it("treats the OpenAI filter as the friendly view over API-key and OAuth profiles", async () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
[legacyProfileId]: {
type: "oauth",
provider: legacyOpenAIProvider,
access: "legacy-access-secret",
refresh: "legacy-refresh-secret",
expires: 1_800_000_000_000,
email: "legacy@example.com",
},
"openai:user@example.com": {
type: "oauth",
provider: "openai",
@@ -165,14 +155,6 @@ describe("modelsAuthListCommand", () => {
agentId: "main",
authStatePath: "/tmp/openclaw/agents/main/auth-state.json",
profiles: [
{
email: "legacy@example.com",
expiresAt: "2027-01-15T08:00:00.000Z",
id: legacyProfileId,
label: legacyProfileId,
provider: "openai",
type: "oauth",
},
{
id: "openai:api-key-backup",
label: "openai:api-key-backup",

View File

@@ -771,21 +771,6 @@ describe("modelsAuthLoginCommand", () => {
).toBe("/tmp/openclaw/agents/coder");
});
it("normalizes legacy OpenAI auth provider requests to the OpenAI plugin", async () => {
const runtime = createRuntime();
const legacyProvider = ["openai", "codex"].join("-");
await modelsAuthLoginCommand({ provider: legacyProvider }, runtime);
const providerResolutionCall = readMockCallArg(
mocks.resolvePluginProviders,
) as ResolvePluginProvidersCall;
expect(providerResolutionCall.providerRefs).toEqual(["openai"]);
expect(runProviderAuth).toHaveBeenCalledOnce();
const upsertCall = readMockCallArg(mocks.upsertAuthProfileWithLock) as UpsertAuthProfileCall;
expect(upsertCall.credential?.provider).toBe("openai");
});
it("loads the owning plugin for an explicit provider even in a clean config", async () => {
const runtime = createRuntime();
const runClaudeCliMigration = vi.fn().mockResolvedValue({

View File

@@ -65,7 +65,6 @@ import { isRemoteEnvironment } from "../oauth-env.js";
import { loadValidConfigOrThrow, resolveKnownAgentId, updateConfig } from "./shared.js";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
const LEGACY_OPENAI_AUTH_PROVIDER_ID = ["openai", "codex"].join("-");
function resolveManualTokenExpiryMs(expiresIn: string | undefined): number | undefined {
const normalizedExpiresIn = normalizeStringifiedOptionalString(expiresIn);
@@ -152,9 +151,7 @@ function resolveDefaultTokenProfileId(provider: string): string {
function normalizeManualAuthProvider(provider: string): string {
const normalized = normalizeProviderId(provider);
return normalized === "openai" || normalized === LEGACY_OPENAI_AUTH_PROVIDER_ID
? "openai"
: normalized;
return normalized === "openai" ? "openai" : normalized;
}
function isOpenAIProvider(provider: string): boolean {

View File

@@ -97,16 +97,14 @@ describe("formatOpenAIOAuthTlsPreflightFix", () => {
});
describe("shouldRunOpenAIOAuthTlsPrerequisites", () => {
it("runs for pre-doctor legacy OpenAI OAuth profiles", () => {
const legacyOpenAIProvider = ["openai", "codex"].join("-");
it("runs for OpenAI OAuth profiles", () => {
expect(
shouldRunOpenAIOAuthTlsPrerequisites({
cfg: {
auth: {
profiles: {
[`${legacyOpenAIProvider}:default`]: {
provider: legacyOpenAIProvider,
"openai:default": {
provider: "openai",
mode: "oauth",
},
},

View File

@@ -50,10 +50,6 @@ import { OpenClawSchema } from "./zod-schema.js";
const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]);
const BLOCKED_PLUGIN_CANDIDATE_PREFIX = "blocked plugin candidate:";
const LEGACY_CHATGPT_PROVIDER_ID = ["openai", "codex"].join("-");
const LEGACY_CHATGPT_RESPONSES_API = `${LEGACY_CHATGPT_PROVIDER_ID}-responses`;
const OPENAI_PROVIDER_ID = "openai";
const OPENAI_CHATGPT_RESPONSES_API = "openai-chatgpt-responses";
type UnknownIssueRecord = Record<string, unknown>;
type ConfigPathSegment = string | number;
@@ -70,79 +66,14 @@ type AllowedValuesCollection = {
};
type JsonSchemaLike = Record<string, unknown>;
function normalizeLegacyOpenAIProviderForValidation(raw: unknown): unknown {
if (!isRecord(raw) || !isRecord(raw.models) || !isRecord(raw.models.providers)) {
return raw;
}
let providersChanged = false;
const providers = { ...raw.models.providers };
const normalizeProviderConfig = (providerConfig: unknown): unknown => {
if (!isRecord(providerConfig)) {
return providerConfig;
}
let providerChanged = false;
const nextProvider = { ...providerConfig };
if (nextProvider.api === LEGACY_CHATGPT_RESPONSES_API) {
nextProvider.api = OPENAI_CHATGPT_RESPONSES_API;
providerChanged = true;
}
if (Array.isArray(nextProvider.models)) {
const nextModels = nextProvider.models.map((model) => {
if (!isRecord(model) || model.api !== LEGACY_CHATGPT_RESPONSES_API) {
return model;
}
providerChanged = true;
return { ...model, api: OPENAI_CHATGPT_RESPONSES_API };
});
if (providerChanged) {
nextProvider.models = nextModels;
}
}
return providerChanged ? nextProvider : providerConfig;
};
for (const [providerId, providerConfig] of Object.entries(providers)) {
const normalizedProvider = normalizeLowercaseStringOrEmpty(providerId);
const normalizedConfig = normalizeProviderConfig(providerConfig);
if (normalizedProvider !== LEGACY_CHATGPT_PROVIDER_ID) {
if (normalizedConfig !== providerConfig) {
providers[providerId] = normalizedConfig;
providersChanged = true;
}
continue;
}
if (!Object.hasOwn(providers, OPENAI_PROVIDER_ID)) {
providers[OPENAI_PROVIDER_ID] = normalizedConfig;
}
delete providers[providerId];
providersChanged = true;
}
if (!providersChanged) {
return raw;
}
return {
...raw,
models: {
...raw.models,
providers,
},
};
}
function stripDeprecatedValidationKeys(raw: unknown): unknown {
const normalizedRaw = normalizeLegacyOpenAIProviderForValidation(raw);
if (
!isRecord(normalizedRaw) ||
!isRecord(normalizedRaw.commands) ||
!Object.hasOwn(normalizedRaw.commands, "modelsWrite")
) {
return normalizedRaw;
if (!isRecord(raw) || !isRecord(raw.commands) || !Object.hasOwn(raw.commands, "modelsWrite")) {
return raw;
}
const commands = { ...normalizedRaw.commands };
const commands = { ...raw.commands };
delete commands.modelsWrite;
return {
...normalizedRaw,
...raw,
commands,
};
}

View File

@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { validateConfigObjectRaw } from "./validation.js";
import { ModelsConfigSchema } from "./zod-schema.core.js";
describe("ModelsConfigSchema", () => {
@@ -23,36 +22,4 @@ describe("ModelsConfigSchema", () => {
expect(result.success).toBe(true);
});
it("canonicalizes legacy OpenAI ChatGPT response config before validation", () => {
const legacyProvider = ["openai", "codex"].join("-");
const legacyApi = `${legacyProvider}-responses`;
const result = validateConfigObjectRaw({
models: {
providers: {
[legacyProvider]: {
baseUrl: "https://chatgpt.com/backend-api/codex",
api: legacyApi,
models: [
{
id: "gpt-5.5",
name: "GPT-5.5",
api: legacyApi,
},
],
},
},
},
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.config.models?.providers?.openai?.api).toBe("openai-chatgpt-responses");
expect(result.config.models?.providers?.openai?.models?.[0]?.api).toBe(
"openai-chatgpt-responses",
);
expect(result.config.models?.providers).not.toHaveProperty(legacyProvider);
});
});

View File

@@ -24,7 +24,6 @@ const TLS_CERT_ERROR_PATTERNS = [
const OPENAI_AUTH_PROBE_URL =
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email";
const OPENAI_PROVIDER_ID = "openai";
const LEGACY_OPENAI_PROVIDER_ID = ["openai", "codex"].join("-");
type PreflightFailureKind = "tls-cert" | "network";
@@ -85,9 +84,7 @@ function hasOpenAICodexOAuthProfile(cfg: OpenClawConfig): boolean {
return false;
}
return Object.values(profiles).some(
(profile) =>
(profile.provider === OPENAI_PROVIDER_ID || profile.provider === LEGACY_OPENAI_PROVIDER_ID) &&
profile.mode === "oauth",
(profile) => profile.provider === OPENAI_PROVIDER_ID && profile.mode === "oauth",
);
}

View File

@@ -7,6 +7,7 @@ import {
detectChangedLanes,
isLiveDockerPackageScriptOnlyChange,
isPackageScriptOnlyChange,
listChangedPathsFromGit,
} from "../../scripts/changed-lanes.mjs";
import {
buildChangedCheckCrabboxArgs,
@@ -152,6 +153,101 @@ describe("scripts/changed-lanes", () => {
expectLanes(result.lanes, { tooling: true });
});
it("falls back to a two-dot diff when a delegated checkout has no merge base", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-no-merge-base-");
git(dir, ["init", "-q", "--initial-branch=main"]);
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
git(dir, ["add", "README.md"]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"-m",
"initial",
]);
git(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
git(dir, ["switch", "-q", "--orphan", "feature"]);
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
mkdirSync(path.join(dir, "src"), { recursive: true });
writeFileSync(path.join(dir, "src", "committed.ts"), "export const committed = 1;\n", "utf8");
git(dir, ["add", "README.md", "src/committed.ts"]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"-m",
"feature base",
]);
writeFileSync(path.join(dir, "src", "feature.ts"), "export const value = 1;\n", "utf8");
expect(
listChangedPathsFromGit({ base: "origin/main", cwd: dir, includeWorktree: false }),
).toEqual(["src/committed.ts"]);
expect(listChangedPathsFromGit({ base: "origin/main", cwd: dir })).toEqual([
"src/committed.ts",
"src/feature.ts",
]);
});
it("prefers raw sync worktree paths over an implausibly broad no-merge-base diff", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-raw-sync-");
git(dir, ["init", "-q", "--initial-branch=main"]);
for (let index = 0; index < 250; index += 1) {
writeFileSync(path.join(dir, `baseline-${index}.txt`), "baseline\n", "utf8");
}
git(dir, ["add", "."]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"-m",
"initial",
]);
git(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
git(dir, ["switch", "-q", "--orphan", "feature"]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"--allow-empty",
"-m",
"raw sync base",
]);
mkdirSync(path.join(dir, "src"), { recursive: true });
writeFileSync(path.join(dir, "src", "feature.ts"), "export const value = 1;\n", "utf8");
const normalPaths = listChangedPathsFromGit({ base: "origin/main", cwd: dir });
expect(normalPaths.length).toBeGreaterThan(200);
expect(normalPaths).toContain("baseline-0.txt");
expect(normalPaths).toContain("src/feature.ts");
const previousRawSync = process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC;
process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC = "1";
try {
expect(listChangedPathsFromGit({ base: "origin/main", cwd: dir })).toEqual([
"src/feature.ts",
]);
} finally {
if (previousRawSync === undefined) {
delete process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC;
} else {
process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC = previousRawSync;
}
}
});
it("ignores local Crabbox metadata in the default local diff", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-crabbox-");
git(dir, ["init", "-q", "--initial-branch=main"]);
@@ -415,10 +511,9 @@ describe("scripts/changed-lanes", () => {
expect(command.args).toEqual(["check:no-conflict-markers"]);
});
it("delegates local Testbox-mode changed gates before running locally", () => {
it("delegates local changed gates to Crabbox before running locally", () => {
expect(
shouldDelegateChangedCheckToCrabbox(["--base", "origin/main"], {
OPENCLAW_TESTBOX: "1",
PATH: "/usr/bin",
}),
).toBe(true);
@@ -442,6 +537,10 @@ describe("scripts/changed-lanes", () => {
"240m",
"--timing-json",
"--",
"env",
"OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1",
"OPENCLAW_CHANGED_LANES_RAW_SYNC=1",
"CI=1",
"corepack",
"pnpm",
"check:changed",
@@ -452,14 +551,67 @@ describe("scripts/changed-lanes", () => {
]);
});
it("does not delegate dry-run or CI changed gates", () => {
expect(shouldDelegateChangedCheckToCrabbox(["--dry-run"], { OPENCLAW_TESTBOX: "1" })).toBe(
false,
);
it("delegates staged changed gates as explicit remote paths", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-check-changed-staged-delegate-");
git(dir, ["init", "-q", "--initial-branch=main"]);
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
git(dir, ["add", "README.md"]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"-m",
"initial",
]);
mkdirSync(path.join(dir, "src"), { recursive: true });
writeFileSync(path.join(dir, "src", "staged.ts"), "export const staged = 1;\n", "utf8");
git(dir, ["add", "src/staged.ts"]);
const args = buildChangedCheckCrabboxArgs(["--staged", "--timed"], { cwd: dir });
expect(args.slice(args.indexOf("check:changed") + 1)).toEqual([
"--timed",
"--base",
"HEAD",
"--head",
"HEAD",
"--",
"src/staged.ts",
]);
});
it("delegates empty staged changed gates without rediscovering unstaged paths", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-check-changed-empty-staged-delegate-");
git(dir, ["init", "-q", "--initial-branch=main"]);
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
git(dir, ["add", "README.md"]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"-m",
"initial",
]);
mkdirSync(path.join(dir, "src"), { recursive: true });
writeFileSync(path.join(dir, "src", "unstaged.ts"), "export const unstaged = 1;\n", "utf8");
const args = buildChangedCheckCrabboxArgs(["--staged", "--timed"], { cwd: dir });
expect(args.slice(args.indexOf("check:changed") + 1)).toEqual(["--timed", "--no-changes"]);
});
it("does not delegate dry-run, CI, or remote-child changed gates", () => {
expect(shouldDelegateChangedCheckToCrabbox(["--dry-run"], {})).toBe(false);
expect(shouldDelegateChangedCheckToCrabbox([], { GITHUB_ACTIONS: "true" })).toBe(false);
expect(shouldDelegateChangedCheckToCrabbox([], { CI: "1" })).toBe(false);
expect(
shouldDelegateChangedCheckToCrabbox([], { OPENCLAW_TESTBOX: "1", GITHUB_ACTIONS: "true" }),
shouldDelegateChangedCheckToCrabbox([], { OPENCLAW_CHECK_CHANGED_REMOTE_CHILD: "1" }),
).toBe(false);
expect(shouldDelegateChangedCheckToCrabbox([], { OPENCLAW_TESTBOX: "1", CI: "1" })).toBe(false);
});
it("runs changed-check lint lanes under the parent heavy-check lock", () => {

View File

@@ -311,6 +311,10 @@ function expectGroupedShellCommand(remoteCommand: string, command: string): void
}
}
const remoteChangedGateEnvPrefix =
"OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1";
const remoteChangedGateExport = `export ${remoteChangedGateEnvPrefix};`;
afterAll(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
@@ -925,7 +929,10 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(remoteCommand).toContain("node --version >&2");
expect(remoteCommand).toContain('corepack enable --install-directory "$PNPM_HOME"');
expect(remoteCommand).toContain("pnpm --version >&2");
expectGroupedShellCommand(remoteCommand, "node scripts/check-changed.mjs");
expectGroupedShellCommand(
remoteCommand,
`openclaw_crabbox_env ${remoteChangedGateEnvPrefix} node scripts/check-changed.mjs`,
);
});
it("preserves shell commands when bootstrapping raw AWS macOS JavaScript commands", () => {
@@ -939,7 +946,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(result.status).toBe(0);
expect(output.args.filter((arg) => arg === "--shell")).toHaveLength(1);
expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js");
expectGroupedShellCommand(remoteCommand, "pnpm check:changed");
expectGroupedShellCommand(remoteCommand, `${remoteChangedGateExport} pnpm check:changed`);
});
it("bootstraps raw AWS macOS shell scripts that set up before JavaScript commands", () => {
@@ -1141,7 +1148,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js");
expectGroupedShellCommand(remoteCommand, shellScript);
expectGroupedShellCommand(remoteCommand, `${remoteChangedGateExport} ${shellScript}`);
});
it("bootstraps raw AWS macOS shell scripts with command-prefixed JavaScript commands", () => {
@@ -1462,7 +1469,10 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js");
expectGroupedShellCommand(remoteCommand, "pnpm check:changed || true");
expectGroupedShellCommand(
remoteCommand,
`${remoteChangedGateExport} pnpm check:changed || true`,
);
});
it("does not bootstrap non-macOS AWS JavaScript commands", () => {
@@ -1747,7 +1757,46 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(remoteCommand).toContain("git add -A");
expect(remoteCommand).toContain("git diff --cached --quiet");
expect(remoteCommand).toContain("commit -q --no-gpg-sign -m remote-changed-gate-tree");
expect(remoteCommand).toMatch(/&& corepack pnpm check:changed$/u);
expect(remoteCommand).toMatch(
/&& env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed$/u,
);
});
it("bootstraps Git metadata for env-prefixed sparse changed gates", () => {
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
[
"run",
"--provider",
"aws",
"--",
"env",
"OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1",
"OPENCLAW_CHANGED_LANES_RAW_SYNC=1",
"CI=1",
"corepack",
"pnpm",
"check:changed",
],
{
gitResponses: {
[GIT_CONFIG_SPARSE_KEY]: { stdout: "true\n" },
[GIT_STATUS_PORCELAIN_KEY]: { stdout: "" },
[GIT_MERGE_BASE_MAIN_HEAD_KEY]: { stdout: "abc123\n" },
},
},
);
const output = parseFakeCrabboxOutput(result);
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(output.args).toContain("--shell");
expect(remoteCommand).toContain(
"git fetch -q --depth=1 origin abc123:refs/remotes/origin/main",
);
expect(remoteCommand).toMatch(
/&& env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 CI=1 corepack pnpm check:changed$/u,
);
});
it("preserves macOS JS bootstrapping for sparse changed gates on remote raw syncs", () => {
@@ -1771,7 +1820,10 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
"git fetch -q --depth=1 origin abc123:refs/remotes/origin/main",
);
expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js");
expectGroupedShellCommand(remoteCommand, "pnpm check:changed");
expectGroupedShellCommand(
remoteCommand,
`openclaw_crabbox_env ${remoteChangedGateEnvPrefix} pnpm check:changed`,
);
});
it("preserves macOS JS and Git bootstraps for sparse shell changed gates with setup", () => {
@@ -1794,7 +1846,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(output.args.filter((arg) => arg === "--shell")).toHaveLength(1);
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js");
expectGroupedShellCommand(remoteCommand, shellScript);
expectGroupedShellCommand(remoteCommand, `${remoteChangedGateExport} ${shellScript}`);
});
it("preserves macOS JS and Git bootstraps for shell-wrapped sparse changed gates", () => {
@@ -1816,7 +1868,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(result.status).toBe(0);
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js");
expectGroupedShellCommand(remoteCommand, shellScript);
expectGroupedShellCommand(remoteCommand, `${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for assignment-prefix command substitutions", () => {
@@ -1837,7 +1889,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain(`&& ${shellScript}`);
expect(remoteCommand).toContain(`&& ${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for command-prefixed shell commands", () => {
@@ -1858,7 +1910,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain(`&& ${shellScript}`);
expect(remoteCommand).toContain(`&& ${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for bash -lc shell commands", () => {
@@ -1883,7 +1935,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(remoteCommand).toContain(
"git fetch -q --depth=1 origin abc123:refs/remotes/origin/main",
);
expect(remoteCommand).toContain(`&& ${shellScript}`);
expect(remoteCommand).toContain(`&& ${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for shell option values before -c", () => {
@@ -1904,7 +1956,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain(`&& ${shellScript}`);
expect(remoteCommand).toContain(`&& ${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for attached shell option values before -c", () => {
@@ -1925,7 +1977,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain(`&& ${shellScript}`);
expect(remoteCommand).toContain(`&& ${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for grouped shell options before -c", () => {
@@ -1946,7 +1998,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain(`&& ${shellScript}`);
expect(remoteCommand).toContain(`&& ${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for absolute time-prefixed shell commands", () => {
@@ -1967,7 +2019,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? "");
expect(result.status).toBe(0);
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain(`&& ${shellScript}`);
expect(remoteCommand).toContain(`&& ${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for timeout-wrapped shell commands", () => {
@@ -1992,7 +2044,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(remoteCommand).toContain(
"git fetch -q --depth=1 origin abc123:refs/remotes/origin/main",
);
expect(remoteCommand).toContain(`&& ${shellScript}`);
expect(remoteCommand).toContain(`&& ${remoteChangedGateExport} ${shellScript}`);
});
it("preserves sparse changed-gate Git bootstrap for direct timeout-wrapped node commands", () => {
@@ -2027,7 +2079,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(output.args).toContain("--shell");
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toMatch(
/&& timeout 1200s node scripts\/check-changed\.mjs --base origin\/main --head HEAD$/u,
/&& env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 timeout 1200s node scripts\/check-changed\.mjs --base origin\/main --head HEAD$/u,
);
});
@@ -2049,7 +2101,9 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(result.status).toBe(0);
expect(output.args).toContain("--shell");
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toMatch(/&& timeout 1200s bash -lc 'pnpm check:changed'$/u);
expect(remoteCommand).toMatch(
/&& env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 timeout 1200s bash -lc 'pnpm check:changed'$/u,
);
});
it("does not treat quoted sparse shell text as a changed gate", () => {
@@ -2191,7 +2245,9 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(remoteCommand).toContain(
"git fetch -q --depth=1 origin abc123:refs/remotes/origin/main",
);
expect(remoteCommand).toMatch(/&& env CI=1 pnpm check:changed$/u);
expect(remoteCommand).toMatch(
/&& export OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1; env CI=1 pnpm check:changed$/u,
);
});
it("does not inject the POSIX changed-gate bootstrap for Windows targets", () => {