From b61e89fbb585ead4c609776ac3ec27ae463fa27e Mon Sep 17 00:00:00 2001 From: Colafornia Date: Tue, 18 Aug 2026 18:17:58 +0800 Subject: [PATCH 1/2] feat: isolate Codex providers per hat --- README.md | 19 +++-- README.zh-CN.md | 17 ++--- docs/advanced.md | 14 ++++ src/commands/run.ts | 26 ++++--- src/core/codex.ts | 60 +++++++++++++++ src/core/env.ts | 25 ++++++- test/codex.test.ts | 66 +++++++++++++++++ test/env.test.ts | 7 ++ test/integration.test.ts | 154 ++++++++++++++++++++++++++++++++++++++- 9 files changed, 356 insertions(+), 32 deletions(-) create mode 100644 src/core/codex.ts create mode 100644 test/codex.test.ts diff --git a/README.md b/README.md index 7d913e3..8d03640 100644 --- a/README.md +++ b/README.md @@ -63,26 +63,24 @@ the selected hat. See [Advanced configuration](docs/advanced.md) for env references, local models, and manual configuration. -## Share an environment across CLIs +## Use one environment across CLIs -Point multiple hats at the same environment file when Claude and Codex use the same -company gateway: +Give the hat a default command, then replace it after `--` when you want another CLI: ```toml -[profiles.write] +[profiles.work] launch = "claude" env_file = "~/.config/company-ai.env" - -[profiles.review] -launch = "codex" -env_file = "~/.config/company-ai.env" ``` ```bash -hats write -hats review # run in another terminal +hats work +hats work -- codex ``` +The `--` boundary must immediately follow the hat name. Without it, trailing arguments +are appended to the default command. + ## Optional: isolate CLI state Provider environments are process-local by default, but hats for the same CLI still @@ -103,6 +101,7 @@ the underlying CLI. ```text hats add [ ] create a hat hats [args...] launch a hat (same as hats run ) +hats -- replace the hat's default command hats edit open the config in $EDITOR hats ls list hats ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 8c4efe5..27db25a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,25 +56,23 @@ hats 不修改全局配置。每个 hat 只作用于它启动的进程,因此 环境变量引用、本地模型和手动配置等进阶用法,请参阅[高级配置](docs/advanced.md)。 -## 在多个 CLI 之间共享环境 +## 让多个 CLI 使用同一个环境 -如果 Claude 和 Codex 都连接同一个公司网关,可以让多个 hat 共用一份环境变量文件: +为 hat 设置默认命令;需要换用另一个 CLI 时,在 `--` 后提供替代命令: ```toml -[profiles.write] +[profiles.work] launch = "claude" env_file = "~/.config/company-ai.env" - -[profiles.review] -launch = "codex" -env_file = "~/.config/company-ai.env" ``` ```bash -hats write -hats review # 在另一个终端中运行 +hats work +hats work -- codex ``` +`--` 必须紧跟 hat 名称。没有 `--` 时,后续参数仍会追加到默认命令。 + ## 可选:隔离 CLI 状态 provider 环境默认按进程隔离,但同一个 CLI 的多个 hat 仍会共用其默认配置目录。如果还需要隔离设置、插件和历史记录,请添加 `--isolated`: @@ -92,6 +90,7 @@ hats add personal codex --isolated ```text hats add [<名称> <命令...>] 创建一个 hat hats [参数...] 启动一个 hat(等同于 hats run ) +hats -- <命令...> 替换 hat 的默认启动命令 hats edit 在 $EDITOR 中打开配置文件 hats ls 列出所有 hat ``` diff --git a/docs/advanced.md b/docs/advanced.md index 71a39b6..070d526 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -51,6 +51,16 @@ env = { COMPANY_AI_URL=https://gateway.example ``` +When the command that Hats actually launches is Codex, a non-empty +`OPENAI_BASE_URL` and `OPENAI_API_KEY` make the Hat the provider source. Hats passes +process-local Codex `-c` overrides for `model_provider`, `base_url`, and `env_key`. +It does not write a profile file, and the secret stays in the process environment. The +endpoint must support the Responses API. + +If a Hat contains provider credentials but lacks either required Codex value, Hats +stops before launch instead of inheriting the global Codex provider. An explicit Codex +`--profile` or `-c model_provider=...` is treated as a user override. + ## Local AI model Run a local Claude-compatible CLI without inheriting a company gateway: @@ -174,3 +184,7 @@ profile needs its own supported config home: This separates local CLI state; it does not guarantee that multiple OAuth subscriptions can coexist. `--isolated` only infers from a bare `codex` or `claude` first token. Set the config-home environment variable by hand for wrappers and custom launchers. + +For `hats -- `, hats selects the isolated config-home variable from the +replacement command. An unknown or unsupported command still receives the hat's +process-local environment, while the launch banner reports `config: (environment only)`. diff --git a/src/commands/run.ts b/src/commands/run.ts index 8d7f1e5..fe8bbde 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -5,11 +5,12 @@ import { loadConfig, type Profile } from "../core/config.js"; import { getProfile } from "../core/profile.js"; import { assembleEnv } from "../core/env.js"; import { parseLaunch, runChild } from "../core/spawn.js"; +import { adaptCodex } from "../core/codex.js"; -function banner(profile: Profile, env: { configDir?: string; stripped: string[] }): void { +function banner(profile: Profile, env: { configDir?: string; environmentOnly?: boolean; stripped: string[] }): void { const parts: string[] = [`🎩 ${profile.name}`]; if (profile.desc) parts.push(profile.desc); - parts.push(`config: ${env.configDir ?? "(default)"}`); + parts.push(`config: ${env.environmentOnly ? "(environment only)" : env.configDir ?? "(default)"}`); if (env.stripped.length) parts.push(`stripped ${env.stripped.length}`); // eslint-disable-next-line no-console console.error(`\x1b[36m${parts.join(" · ")}\x1b[0m`); @@ -63,11 +64,8 @@ async function launch( profile: Profile, extraArgs: string[], override?: string[], + track = true, ): Promise { - const { env, ...summary } = await assembleEnv(profile); - if (summary.configDir) mkdirSync(summary.configDir, { recursive: true }); - banner(profile, { configDir: summary.configDir, stripped: summary.stripped }); - let argv: string[]; if (override && override.length) { argv = override; @@ -78,8 +76,13 @@ async function launch( } argv = [...argv, ...extraArgs]; + const { env, ...summary } = await assembleEnv(profile, override && track ? argv[0] : undefined); + if (track) argv = adaptCodex(profile.name, argv, env); + if (summary.configDir) mkdirSync(summary.configDir, { recursive: true }); + banner(profile, summary); + const run = () => runChild(argv, { env }); - return override ? run() : withHerdrHat(profile.name, () => withTmuxHat(profile.name, run)); + return track ? withHerdrHat(profile.name, () => withTmuxHat(profile.name, run)) : run(); } export const runCommand = new Command("run") @@ -90,7 +93,12 @@ export const runCommand = new Command("run") .action(async (name: string, args: string[]) => { const cfg = loadConfig(name); const profile = getProfile(cfg, name); - const code = await launch(profile, args); + const boundary = process.argv.indexOf("--", 2); + const hatIndex = process.argv[2] === "run" ? 3 : 2; + if (boundary > hatIndex + 1) throw new Error("-- must immediately follow the hat name"); + const override = boundary < 0 ? undefined : process.argv.slice(boundary + 1); + if (override?.length === 0) throw new Error("replacement command is empty"); + const code = await launch(profile, override ? [] : args, override); process.exit(code); }); @@ -107,6 +115,6 @@ export const execCommand = new Command("exec") console.error("exec requires a command. Usage: hats exec -- [args...]"); process.exit(2); } - const code = await launch(profile, [], args); + const code = await launch(profile, [], args, false); process.exit(code); }); diff --git a/src/core/codex.ts b/src/core/codex.ts new file mode 100644 index 0000000..a023d9e --- /dev/null +++ b/src/core/codex.ts @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; +import { basename } from "node:path"; + +const PROVIDER_ENV = [ + "OPENAI_BASE_URL", + "OPENAI_API_KEY", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_VERTEX_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", +]; + +function hasProviderOverride(argv: string[]): boolean { + for (let i = 1; i < argv.length && argv[i] !== "--"; i++) { + const arg = argv[i]; + if (arg === "-p" || arg === "--profile" || arg.startsWith("-p=") || arg.startsWith("--profile=")) return true; + const config = + arg === "-c" || arg === "--config" + ? argv[++i] + : arg.startsWith("-c=") || arg.startsWith("--config=") + ? arg.slice(arg.indexOf("=") + 1) + : undefined; + if (config && /^\s*(model_provider|profile)\s*=/.test(config)) return true; + } + return false; +} + +/** Inject a process-local provider only when this Hat owns enough Codex provider data. */ +export function adaptCodex(profileName: string, argv: string[], env: Record): string[] { + if (basename(argv[0]) !== "codex" || hasProviderOverride(argv)) return argv; + if (!PROVIDER_ENV.some((key) => env[key] !== undefined)) return argv; + + const missing = ["OPENAI_BASE_URL", "OPENAI_API_KEY"].filter((key) => !env[key]?.trim()); + if (missing.length) { + throw new Error(`Codex provider for hat "${profileName}" is incomplete; missing ${missing.join(", ")}`); + } + try { + const url = new URL(env.OPENAI_BASE_URL); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(); + } catch { + throw new Error(`Codex provider for hat "${profileName}" has invalid OPENAI_BASE_URL`); + } + + const id = `hats-${createHash("sha256").update(profileName).digest("hex")}`; + const injected = [ + "-c", + `model_provider=${JSON.stringify(id)}`, + "-c", + `model_providers.${id}.name="Hats"`, + "-c", + `model_providers.${id}.base_url=${JSON.stringify(env.OPENAI_BASE_URL)}`, + "-c", + `model_providers.${id}.env_key="OPENAI_API_KEY"`, + ]; + return [argv[0], ...injected, ...argv.slice(1)]; +} diff --git a/src/core/env.ts b/src/core/env.ts index 90b907f..7086201 100644 --- a/src/core/env.ts +++ b/src/core/env.ts @@ -1,8 +1,9 @@ import { readFileSync, existsSync } from "node:fs"; +import { basename } from "node:path"; import { parse as parseDotenv } from "dotenv"; import type { Profile } from "./config.js"; import { expandTilde, resolveForRun, refKind } from "./resolve.js"; -import { TOOL_CREDENTIAL_ENV, TOOL_HOME_VARS } from "./tools.js"; +import { CredentialStorage, TOOLS, TOOL_CREDENTIAL_ENV, TOOL_HOME_VARS } from "./tools.js"; /** * Provider / config prefixes stripped from the *inherited* environment so a @@ -38,6 +39,8 @@ export interface AssembledEnv { env: Record; /** Resolved config-home var (CLAUDE_CONFIG_DIR / CODEX_HOME / GEMINI_CLI_HOME), if any. */ configDir?: string; + /** An isolated hat launched a command whose CLI state Hats cannot safely isolate. */ + environmentOnly?: boolean; /** Names of inherited provider vars that were stripped (for visibility). */ stripped: string[]; } @@ -49,7 +52,7 @@ export interface AssembledEnv { * references (env:/file:/cmd:) are used verbatim (no re-expansion, so a token * containing `$` is not mangled). */ -export async function assembleEnv(profile: Profile): Promise { +export async function assembleEnv(profile: Profile, command?: string): Promise { const env: Record = {}; const stripped: string[] = []; for (const [k, v] of Object.entries(process.env)) { @@ -99,9 +102,25 @@ export async function assembleEnv(profile: Profile): Promise { env[k] = expandVars(expandTilde(env[k]), env); } + const configuredHomeVar = Object.keys(env).find((key) => TOOL_HOME_VARS.has(key)); + const configuredHome = configuredHomeVar ? env[configuredHomeVar] : undefined; + const tool = command ? TOOLS[basename(command)] : undefined; + let environmentOnly = false; + if (configuredHome && command) { + for (const key of TOOL_HOME_VARS) delete env[key]; + if ( + tool?.homeVar && + [CredentialStorage.ConfigHome, CredentialStorage.DirectoryKeychain].includes(tool.credentialStorage) + ) { + env[tool.homeVar] = configuredHome; + } else { + environmentOnly = true; + } + } + env.HATS_PROFILE = profile.name; const configDir = Object.keys(env).find((key) => TOOL_HOME_VARS.has(key)); const resolvedConfigDir = configDir ? env[configDir] : undefined; - return { env, configDir: resolvedConfigDir, stripped }; + return { env, configDir: resolvedConfigDir, environmentOnly, stripped }; } diff --git a/test/codex.test.ts b/test/codex.test.ts new file mode 100644 index 0000000..310214e --- /dev/null +++ b/test/codex.test.ts @@ -0,0 +1,66 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { adaptCodex } from "../src/core/codex.js"; + +function fixture() { + const home = mkdtempSync(join(tmpdir(), "hats-codex-")); + return { + home, + env: { + CODEX_HOME: home, + OPENAI_BASE_URL: "https://gateway.example/v1", + OPENAI_API_KEY: "secret", + }, + }; +} + +describe("Codex provider adapter", () => { + test("injects a process-local provider and preserves model arguments", () => { + const { home, env } = fixture(); + try { + const argv = adaptCodex("工作.hat", ["/opt/bin/codex", "-m", "gpt-test"], env); + const id = "hats-f36edb208ada2d3f1ad5c4e6497cbbaf36e0af034d17d1fa94af68835cf02ccc"; + assert.deepEqual(argv, [ + "/opt/bin/codex", + "-c", `model_provider=${JSON.stringify(id)}`, + "-c", `model_providers.${id}.name="Hats"`, + "-c", `model_providers.${id}.base_url="https://gateway.example/v1"`, + "-c", `model_providers.${id}.env_key="OPENAI_API_KEY"`, + "-m", "gpt-test", + ]); + assert.equal(argv.includes("secret"), false); + assert.deepEqual(readdirSync(home), []); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("fails before launch when Hat provider data is incomplete", () => { + const { home, env } = fixture(); + try { + delete (env as Partial).OPENAI_BASE_URL; + assert.throws(() => adaptCodex("rc", ["codex"], env), /missing OPENAI_BASE_URL/); + assert.deepEqual(readdirSync(home), []); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("leaves explicit provider selection and non-Codex commands alone", () => { + const { home, env } = fixture(); + try { + const explicit = ["codex", "-c", "model_provider=4ai"]; + assert.equal(adaptCodex("4ai", explicit, env), explicit); + const profile = ["codex", "-p=personal"]; + assert.equal(adaptCodex("personal", profile, env), profile); + const ollama = ["ollama", "launch", "codex"]; + assert.equal(adaptCodex("local", ollama, env), ollama); + assert.deepEqual(readdirSync(home), []); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/test/env.test.ts b/test/env.test.ts index ce2285a..9aa5272 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -83,6 +83,13 @@ describe("isolation / provider-prefix strip", () => { assert.notEqual(a.configDir, b.configDir); }); + test("recognizes a replacement CLI by executable basename", async () => { + const home = join(tmpdir(), "hats-codex-home"); + const { env } = await assembleEnv({ name: "a", env: { CLAUDE_CONFIG_DIR: home } }, "/opt/bin/codex"); + assert.equal(env.CODEX_HOME, home); + assert.equal(env.CLAUDE_CONFIG_DIR, undefined); + }); + test("${VAR} expansion resolves against the assembled env", async () => { const profile: Profile = { name: "r", diff --git a/test/integration.test.ts b/test/integration.test.ts index 51a2410..986faa5 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,4 +1,4 @@ -import { describe, test, before, after } from "node:test"; +import { describe, test, before, after, type TestContext } from "node:test"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; @@ -70,6 +70,29 @@ function runCli(args: string[], env: Record): Promise }); } +function setupIsolatedHats(t: TestContext) { + const home = mkdtempSync(join(tmpdir(), "hats-test-")); + const bin = join(home, "bin"); + mkdirSync(bin); + t.after(() => rmSync(home, { recursive: true, force: true })); + + return { + home, + writeConfig: (config: string) => writeFileSync(join(home, "config.toml"), config), + writeCommand: (name: string, script: string) => + writeFileSync(join(bin, name), script, { mode: 0o755 }), + run: (args: string[], env: Record = {}) => + runCli( + args, + childEnv({ + HATS_HOME: home, + PATH: `${bin}:${process.env.PATH ?? ""}`, + ...env, + }), + ), + }; +} + function readLines(path: string): string[] { return readFileSync(path, "utf8").trim().split("\n"); } @@ -346,6 +369,26 @@ describe("integration: tmux active hat metadata", () => { } }); + test("tracks the active hat for a replacement command", async () => { + const { home, bin, log } = tmuxFixture(); + try { + spawnSync(join(bin, "tmux"), [], { env: { ...process.env, EVENT_LOG: "/dev/null" } }); + const r = await runCli( + ["work", "--", "node", "-e", "require('node:fs').appendFileSync(process.env.EVENT_LOG, 'replacement:' + process.env.HATS_PROFILE + '\\n')"], + childEnv({ HATS_HOME: home, TMUX: "socket", TMUX_PANE: "%10", EVENT_LOG: log, PATH: `${bin}:${process.env.PATH ?? ""}` }), + ); + + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(readLines(log), [ + "tmux:set-option -p -t %10 @hats_profile work", + "replacement:work", + "tmux:set-option -p -u -t %10 @hats_profile", + ]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("tmux failures do not change the agent exit code", async () => { const { home, bin, log } = tmuxFixture("exit 9\n", "", 7); try { @@ -478,7 +521,116 @@ describe("integration: hats exec through the real CLI", () => { }); }); +describe("integration: replacement command isolation", () => { + test("preserves a manually configured CLI home for the default launch", async (t) => { + const hats = setupIsolatedHats(t); + const configHome = join(hats.home, "manual-claude-home"); + hats.writeConfig( + `[profiles.work]\nlaunch = "wrapper"\nenv = { CLAUDE_CONFIG_DIR = "${configHome}" }\n`, + ); + hats.writeCommand("wrapper", "#!/bin/sh\nprintf '%s\\n' \"$CLAUDE_CONFIG_DIR\"\n"); + + const r = await hats.run(["work"]); + + assert.equal(r.code, 0, r.stderr); + assert.equal(r.stdout.trim(), configHome); + }); + + test("selects the config home for the actual replacement CLI", async (t) => { + const hats = setupIsolatedHats(t); + const configHome = join(hats.home, "homes", "work"); + hats.writeConfig( + `[profiles.work]\nlaunch = "claude"\nenv = { CLAUDE_CONFIG_DIR = "${configHome}" }\n`, + ); + hats.writeCommand( + "codex", + "#!/bin/sh\nprintf '%s\\n%s\\n%s\\n' \"$CODEX_HOME\" \"$CLAUDE_CONFIG_DIR\" \"$(test -d \"$CODEX_HOME\" && echo exists)\"\n", + ); + + const r = await hats.run(["work", "--", "codex"]); + + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(r.stdout.trim().split("\n"), [configHome, "", "exists"]); + }); + + test("runs an unknown replacement with environment isolation only", async (t) => { + const hats = setupIsolatedHats(t); + const configHome = join(hats.home, "homes", "work"); + hats.writeConfig( + `[profiles.work]\nlaunch = "claude"\nenv = { CLAUDE_CONFIG_DIR = "${configHome}", PROVIDER_URL = "relay" }\n`, + ); + hats.writeCommand( + "other-ai", + "#!/bin/sh\nprintf '%s\\n%s\\n' \"$CLAUDE_CONFIG_DIR\" \"$PROVIDER_URL\"\n", + ); + + const r = await hats.run(["work", "--", "other-ai"]); + + assert.equal(r.code, 0, r.stderr); + assert.equal(r.stdout, "\nrelay\n"); + assert.match(r.stderr, /config: \(environment only\)/); + }); + + test("adapts the actual replacement Codex from Hat provider env", async (t) => { + const hats = setupIsolatedHats(t); + const codexHome = join(hats.home, "codex-home"); + hats.writeConfig( + `[profiles.work]\nlaunch = "claude"\nenv = { CODEX_HOME = "${codexHome}", OPENAI_BASE_URL = "https://gateway.example/v1", OPENAI_API_KEY = "secret" }\n`, + ); + hats.writeCommand("codex", "#!/usr/bin/env node\nconsole.log(JSON.stringify(process.argv.slice(2)))\n"); + const codex = join(hats.home, "bin", "codex"); + + const r = await hats.run(["work", "--", codex, "-m", "gpt-test"]); + + assert.equal(r.code, 0, r.stderr); + const args = JSON.parse(r.stdout) as string[]; + const id = args[1].slice('model_provider="'.length, -1); + assert.match(id, /^hats-[a-f0-9]{64}$/); + assert.deepEqual(args, [ + "-c", `model_provider=${JSON.stringify(id)}`, + "-c", `model_providers.${id}.name="Hats"`, + "-c", `model_providers.${id}.base_url="https://gateway.example/v1"`, + "-c", `model_providers.${id}.env_key="OPENAI_API_KEY"`, + "-m", "gpt-test", + ]); + + const exec = await hats.run(["exec", "work", "--", codex]); + assert.equal(exec.code, 0, exec.stderr); + assert.equal(exec.stdout, "[]\n"); + }); +}); + describe("integration: hat shorthand", () => { + test("`hats -- ` replaces the default launch command", async () => { + const defaultScript = join(tmpHome, "default.mjs"); + const replacementScript = join(tmpHome, "replacement.mjs"); + writeFileSync(defaultScript, "console.log('default')\n"); + writeFileSync(replacementScript, "console.log(process.argv.slice(2).join('|'))\n"); + writeFileSync( + join(tmpHome, "config.toml"), + `${CONFIG_TOML}\n[profiles.work]\nlaunch = "node ${defaultScript}"\n`, + ); + + const r = await runCli(["work", "--", "node", replacementScript, "--model", "gpt 5"], childEnv()); + + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.equal(r.stdout.trim(), "--model|gpt 5"); + }); + + test("`hats --` rejects an empty replacement command", async () => { + const r = await runCli(["relay", "--"], childEnv()); + + assert.notEqual(r.code, 0); + assert.match(r.stderr, /replacement command is empty/); + }); + + test("does not silently discard default-command args before `--`", async () => { + const r = await runCli(["relay", "old-arg", "--", "node", "-e", "process.exit(0)"], childEnv()); + + assert.notEqual(r.code, 0); + assert.match(r.stderr, /-- must immediately follow the hat name/); + }); + test("`hats ` runs the hat and preserves trailing args", async () => { const script = join(tmpHome, "argv.mjs"); writeFileSync(script, "console.log(process.argv.slice(2).join('|'))\n"); From 0cd433aeead333b88cd880be94e73ab2b49b9dc0 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Wed, 19 Aug 2026 00:01:58 +0800 Subject: [PATCH 2/2] test: use a neutral Unicode hat name --- test/codex.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/codex.test.ts b/test/codex.test.ts index 310214e..3499c2d 100644 --- a/test/codex.test.ts +++ b/test/codex.test.ts @@ -21,8 +21,8 @@ describe("Codex provider adapter", () => { test("injects a process-local provider and preserves model arguments", () => { const { home, env } = fixture(); try { - const argv = adaptCodex("工作.hat", ["/opt/bin/codex", "-m", "gpt-test"], env); - const id = "hats-f36edb208ada2d3f1ad5c4e6497cbbaf36e0af034d17d1fa94af68835cf02ccc"; + const argv = adaptCodex("café.hat", ["/opt/bin/codex", "-m", "gpt-test"], env); + const id = "hats-3f515fc9f389a544c7e8be985c393a83d688d1a0f270af8162f1910fec19be37"; assert.deepEqual(argv, [ "/opt/bin/codex", "-c", `model_provider=${JSON.stringify(id)}`,