diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index 374f223db6da..debeb23f4267 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -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 -- --reporter=verbose` - Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs ` @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 9b8a66f1fb65..36fca22b0917 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,8 +95,8 @@ Skills own workflows; root owns hard policy and routing. - Tests in a normal source checkout: `pnpm test [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 ` 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/`. - 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`). diff --git a/docs/reference/test.md b/docs/reference/test.md index a2e32908cbcd..ab03b4a63e85 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -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 ` 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 `; 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 ` 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 `; 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 `: 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. diff --git a/extensions/migrate-hermes/auth.ts b/extensions/migrate-hermes/auth.ts index 06cff0a95472..1547c6511239 100644 --- a/extensions/migrate-hermes/auth.ts +++ b/extensions/migrate-hermes/auth.ts @@ -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)); diff --git a/extensions/migrate-hermes/files-and-skills.test.ts b/extensions/migrate-hermes/files-and-skills.test.ts index 1561484caf9a..c5a07afba691 100644 --- a/extensions/migrate-hermes/files-and-skills.test.ts +++ b/extensions/migrate-hermes/files-and-skills.test.ts @@ -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", diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts index 791e56d11272..657810d937cf 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -18,12 +18,10 @@ const { (params: { provider: string; agentDir?: string }) => boolean >(() => false), listProfilesForProviderMock: vi.fn( - (store: { profiles?: Record }, 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 }, 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(); diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index fcb10f368c9d..ffc0e3767806 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -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, diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index aa82d0dfe0ed..3e840634fdbb 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -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() diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index b33ec2c880c9..3fff8384e31f 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -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); diff --git a/src/agents/auth-profiles/oauth-refresh-failure.test.ts b/src/agents/auth-profiles/oauth-refresh-failure.test.ts index e08dda8f5166..8a80898c3c12 100644 --- a/src/agents/auth-profiles/oauth-refresh-failure.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-failure.test.ts @@ -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", ); }); diff --git a/src/agents/auth-profiles/oauth-refresh-failure.ts b/src/agents/auth-profiles/oauth-refresh-failure.ts index 05364edf1cc5..be5ec2315b47 100644 --- a/src/agents/auth-profiles/oauth-refresh-failure.ts +++ b/src/agents/auth-profiles/oauth-refresh-failure.ts @@ -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> = { + "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"); } diff --git a/src/agents/auth-profiles/profiles.test.ts b/src/agents/auth-profiles/profiles.test.ts index 1cd283226e1a..c288604da66a 100644 --- a/src/agents/auth-profiles/profiles.test.ts +++ b/src/agents/auth-profiles/profiles.test.ts @@ -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", }, }, diff --git a/src/agents/model-runtime-aliases.ts b/src/agents/model-runtime-aliases.ts index 252bd6de50b0..f8918f5dff10 100644 --- a/src/agents/model-runtime-aliases.ts +++ b/src/agents/model-runtime-aliases.ts @@ -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([["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 ); } diff --git a/src/agents/provider-auth-aliases.test.ts b/src/agents/provider-auth-aliases.test.ts index eda3b1d7f00a..120718b15f25 100644 --- a/src/agents/provider-auth-aliases.test.ts +++ b/src/agents/provider-auth-aliases.test.ts @@ -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", diff --git a/src/agents/provider-auth-aliases.ts b/src/agents/provider-auth-aliases.ts index 3c5b1bdfdc97..e141b624cb4e 100644 --- a/src/agents/provider-auth-aliases.ts +++ b/src/agents/provider-auth-aliases.ts @@ -25,10 +25,6 @@ type ProviderAuthAliasCandidate = { target: string; }; -const RETIRED_PROVIDER_AUTH_ALIASES: Readonly> = { - [["openai", "codex"].join("-")]: "openai", -}; - const PROVIDER_AUTH_ALIAS_ORIGIN_PRIORITY: Readonly> = { 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; } diff --git a/src/agents/session-file-repair.test.ts b/src/agents/session-file-repair.test.ts index 9a2458eb7127..fabc2fcafec4 100644 --- a/src/agents/session-file-repair.test.ts +++ b/src/agents/session-file-repair.test.ts @@ -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", }, diff --git a/src/agents/session-file-repair.ts b/src/agents/session-file-repair.ts index f7a37250564d..5918ee45208c 100644 --- a/src/agents/session-file-repair.ts +++ b/src/agents/session-file-repair.ts @@ -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; diff --git a/src/commands/auth-choice-legacy.test.ts b/src/commands/auth-choice-legacy.test.ts index 342fadaf35ae..6157ce3bd947 100644 --- a/src/commands/auth-choice-legacy.test.ts +++ b/src/commands/auth-choice-legacy.test.ts @@ -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`, - ); }); }); diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index c2be67d3e894..c594c4fc9168 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -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: { diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts index 1123209f65b4..6a5d89c35733 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts @@ -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): boolean { + return Object.keys(providers).some( + (providerId) => normalizeProviderId(providerId) === OPENAI_PROVIDER_ID, + ); +} + +function normalizeLegacyOpenAIResponsesApi( + providerId: string, + provider: Record, + changes: string[], +): { value: Record; changed: boolean } { + let changed = false; + const next: Record = { ...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, 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", diff --git a/src/commands/models/auth-list.test.ts b/src/commands/models/auth-list.test.ts index 84cda3914955..4ffaabbb8517 100644 --- a/src/commands/models/auth-list.test.ts +++ b/src/commands/models/auth-list.test.ts @@ -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", diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index d74eb6ac927d..fb5d251a2ef7 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -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({ diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 7f6005b65908..97ffd4bf12b1 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -65,7 +65,6 @@ import { isRemoteEnvironment } from "../oauth-env.js"; import { loadValidConfigOrThrow, resolveKnownAgentId, updateConfig } from "./shared.js"; type UpsertAuthProfileParams = Parameters[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 { diff --git a/src/commands/oauth-tls-preflight.test.ts b/src/commands/oauth-tls-preflight.test.ts index bf8230df2644..235a00e7f725 100644 --- a/src/commands/oauth-tls-preflight.test.ts +++ b/src/commands/oauth-tls-preflight.test.ts @@ -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", }, }, diff --git a/src/config/validation.ts b/src/config/validation.ts index d16fd310b64e..814650d02574 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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; type ConfigPathSegment = string | number; @@ -70,79 +66,14 @@ type AllowedValuesCollection = { }; type JsonSchemaLike = Record; -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, }; } diff --git a/src/config/zod-schema.models.test.ts b/src/config/zod-schema.models.test.ts index 69a196391945..aad35819d928 100644 --- a/src/config/zod-schema.models.test.ts +++ b/src/config/zod-schema.models.test.ts @@ -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); - }); }); diff --git a/src/plugins/provider-openai-chatgpt-oauth-tls.ts b/src/plugins/provider-openai-chatgpt-oauth-tls.ts index 320f3bd65707..0354036f068c 100644 --- a/src/plugins/provider-openai-chatgpt-oauth-tls.ts +++ b/src/plugins/provider-openai-chatgpt-oauth-tls.ts @@ -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", ); } diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 88adc25fd02b..069fdd92c1bf 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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", () => { diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index e3150b94860b..10d8400acb80 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -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", () => {