authorgravatar for 105842516+jamesx0416@users.noreply.github.comJames <105842516+jamesx0416@users.noreply.github.com> 2026-07-20 19:04:03+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 11:04:03+02:00
log40c0ab088308fb010a3db488c545f10aa7179ed2
treeeedd0d470ab9e5592e594972988d5ad9e6b6db67
parent33f1cb422461bf0f072b9f181734e9b654d1ca69
signature Signed by PGP key B5690EEEBB952194

Add Codex launch arguments setting (#2892)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Julius Marminge <jmarminge@gmail.com> Co-authored-by: root <root@localhost.localdomain>

17 files changed, 414 insertions(+), 16 deletions(-)

apps/server/src/provider/Layers/CodexAdapter.test.ts+64
......@@ -279,6 +279,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
279279 NodeAssert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], {
280280 binaryPath: "codex",
281281 cwd: process.cwd(),
282 launchArgs: "",
282283 model: "gpt-5.3-codex",
283284 providerInstanceId: ProviderInstanceId.make("codex"),
284285 serviceTier: "priority",
......@@ -359,6 +360,69 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
359360 }),
360361 );
361362
363 it.effect("passes configured launch args into the session runtime", () => {
364 const runtimeFactory = makeRuntimeFactory();
365 const layer = Layer.effect(
366 CodexAdapter,
367 Effect.gen(function* () {
368 const codexConfig = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" });
369 return yield* makeCodexAdapter(codexConfig, {
370 makeRuntime: runtimeFactory.factory,
371 });
372 }),
373 ).pipe(
374 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
375 Layer.provideMerge(ServerSettingsService.layerTest()),
376 Layer.provideMerge(providerSessionDirectoryTestLayer),
377 Layer.provideMerge(NodeServices.layer),
378 );
379
380 return Effect.gen(function* () {
381 const adapter = yield* CodexAdapter;
382 yield* adapter.startSession({
383 provider: ProviderDriverKind.make("codex"),
384 threadId: asThreadId("sess-launch-args"),
385 runtimeMode: "full-access",
386 });
387
388 const runtime = runtimeFactory.lastRuntime;
389 NodeAssert.ok(runtime);
390 NodeAssert.equal(runtime.options.launchArgs, "--strict-config --enable foo");
391 }).pipe(Effect.provide(layer));
392 });
393
394 it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for the session runtime", () => {
395 const runtimeFactory = makeRuntimeFactory();
396 const layer = Layer.effect(
397 CodexAdapter,
398 Effect.gen(function* () {
399 const codexConfig = decodeCodexSettings({ launchArgs: "--enable settings-feature" });
400 return yield* makeCodexAdapter(codexConfig, {
401 environment: { T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --enable env-feature " },
402 makeRuntime: runtimeFactory.factory,
403 });
404 }),
405 ).pipe(
406 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
407 Layer.provideMerge(ServerSettingsService.layerTest()),
408 Layer.provideMerge(providerSessionDirectoryTestLayer),
409 Layer.provideMerge(NodeServices.layer),
410 );
411
412 return Effect.gen(function* () {
413 const adapter = yield* CodexAdapter;
414 yield* adapter.startSession({
415 provider: ProviderDriverKind.make("codex"),
416 threadId: asThreadId("sess-launch-args-env"),
417 runtimeMode: "full-access",
418 });
419
420 const runtime = runtimeFactory.lastRuntime;
421 NodeAssert.ok(runtime);
422 NodeAssert.equal(runtime.options.launchArgs, "--strict-config --enable env-feature");
423 }).pipe(Effect.provide(layer));
424 });
425
362426 it.effect("maps codex model options for the adapter's bound custom instance id", () => {
363427 const customInstanceId = ProviderInstanceId.make("codex_personal");
364428 const customRuntimeFactory = makeRuntimeFactory();
apps/server/src/provider/Layers/CodexAdapter.ts+2
......@@ -61,6 +61,7 @@ import {
6161 type CodexSessionRuntimeShape,
6262} from "./CodexSessionRuntime.ts";
6363import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
64import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
6465const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);
6566const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);
6667const isCodexSessionRuntimeThreadIdMissingError = Schema.is(
......@@ -1399,6 +1400,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
13991400 providerInstanceId: boundInstanceId,
14001401 cwd: input.cwd ?? process.cwd(),
14011402 binaryPath: codexConfig.binaryPath,
1403 launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment),
14021404 ...(options?.environment ? { environment: options.environment } : {}),
14031405 ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),
14041406 ...(isCodexResumeCursorSchema(input.resumeCursor)
apps/server/src/provider/Layers/CodexProvider.ts+12-4
......@@ -26,6 +26,7 @@ import { ServerSettingsError } from "@t3tools/contracts";
2626
2727import { createModelCapabilities } from "@t3tools/shared/model";
2828import { resolveSpawnCommand } from "@t3tools/shared/shell";
29import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
2930import {
3031 AUTH_PROBE_TIMEOUT_MS,
3132 buildServerProvider,
......@@ -289,6 +290,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
289290const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
290291 readonly binaryPath: string;
291292 readonly homePath?: string;
293 readonly launchArgs?: string;
292294 readonly cwd: string;
293295 readonly customModels?: ReadonlyArray<string>;
294296 readonly environment?: NodeJS.ProcessEnv;
......@@ -303,10 +305,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
303305 ...input.environment,
304306 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
305307 };
306 const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], {
307 env: environment,
308 extendEnv: true,
309 });
308 const spawnCommand = yield* resolveSpawnCommand(
309 input.binaryPath,
310 codexAppServerArgs(input.launchArgs),
311 {
312 env: environment,
313 extendEnv: true,
314 },
315 );
310316 const child = yield* spawner
311317 .spawn(
312318 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
......@@ -465,6 +471,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
465471 probe: (input: {
466472 readonly binaryPath: string;
467473 readonly homePath?: string;
474 readonly launchArgs?: string;
468475 readonly cwd: string;
469476 readonly customModels: ReadonlyArray<string>;
470477 readonly environment?: NodeJS.ProcessEnv;
......@@ -503,6 +510,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
503510 const probeResult = yield* probe({
504511 binaryPath: codexSettings.binaryPath,
505512 homePath: codexSettings.homePath,
513 launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment),
506514 cwd: process.cwd(),
507515 customModels: codexSettings.customModels,
508516 environment: resolvedEnvironment,
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+28
......@@ -13,6 +13,7 @@ import {
1313 CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
1414 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
1515} from "../CodexDeveloperInstructions.ts";
16import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
1617import {
1718 buildTurnStartParams,
1819 hasConfiguredMcpServer,
......@@ -289,6 +290,33 @@ describe("hasConfiguredMcpServer", () => {
289290 });
290291});
291292
293describe("codexSessionAppServerArgs", () => {
294 it("keeps the app-server subcommand when explicit args are provided", () => {
295 NodeAssert.deepStrictEqual(codexSessionAppServerArgs(["-c", "model=gpt-5"], undefined), [
296 "app-server",
297 "-c",
298 "model=gpt-5",
299 ]);
300 });
301
302 it("keeps launch args when explicit app-server args are provided", () => {
303 NodeAssert.deepStrictEqual(
304 codexSessionAppServerArgs(
305 ["-c", "mcp_servers.t3-code.url=http://127.0.0.1/mcp"],
306 "--strict-config --enable foo",
307 ),
308 [
309 "app-server",
310 "--strict-config",
311 "--enable",
312 "foo",
313 "-c",
314 "mcp_servers.t3-code.url=http://127.0.0.1/mcp",
315 ],
316 );
317 });
318});
319
292320describe("isRecoverableThreadResumeError", () => {
293321 it("matches missing thread errors", () => {
294322 NodeAssert.equal(
apps/server/src/provider/Layers/CodexSessionRuntime.ts+7-5
......@@ -36,6 +36,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc";
3636import * as EffectCodexSchema from "effect-codex-app-server/schema";
3737
3838import { buildCodexInitializeParams } from "./CodexProvider.ts";
39import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
3940import { expandHomePath } from "../../pathExpansion.ts";
4041import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts";
4142const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse);
......@@ -97,6 +98,7 @@ export interface CodexSessionRuntimeOptions {
9798 readonly providerInstanceId?: ProviderInstanceId;
9899 readonly binaryPath: string;
99100 readonly homePath?: string;
101 readonly launchArgs?: string;
100102 readonly environment?: NodeJS.ProcessEnv;
101103 readonly cwd: string;
102104 readonly runtimeMode: RuntimeMode;
......@@ -717,11 +719,11 @@ export const makeCodexSessionRuntime = (
717719 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
718720 };
719721 const extendEnv = options.environment === undefined;
720 const spawnCommand = yield* resolveSpawnCommand(
721 options.binaryPath,
722 ["app-server", ...(options.appServerArgs ?? [])],
723 { env, extendEnv },
724 );
722 const appServerArgs = codexSessionAppServerArgs(options.appServerArgs, options.launchArgs);
723 const spawnCommand = yield* resolveSpawnCommand(options.binaryPath, appServerArgs, {
724 env,
725 extendEnv,
726 });
725727 const child = yield* spawner
726728 .spawn(
727729 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+1
......@@ -61,6 +61,7 @@ const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
6161 binaryPath: "codex",
6262 homePath: "",
6363 shadowHomePath: "",
64 launchArgs: "",
6465 customModels: [],
6566 ...overrides,
6667});
apps/server/src/provider/Layers/ProviderRegistry.test.ts+16
......@@ -55,6 +55,7 @@ const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTING
5555
5656const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
5757const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
58const decodeCodexSettings = Schema.decodeSync(CodexSettings);
5859const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({
5960 enabled: false,
6061});
......@@ -348,6 +349,21 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
348349 }),
349350 );
350351
352 it.effect("passes configured launch args to the Codex provider probe", () =>
353 Effect.gen(function* () {
354 let observedLaunchArgs: string | undefined;
355 const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" });
356
357 const status = yield* checkCodexProviderStatus(settings, (input) => {
358 observedLaunchArgs = input.launchArgs;
359 return Effect.succeed(makeCodexProbeSnapshot());
360 });
361
362 assert.strictEqual(status.status, "ready");
363 assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo");
364 }),
365 );
366
351367 it.effect("returns unauthenticated when app-server requires OpenAI auth", () =>
352368 Effect.gen(function* () {
353369 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
apps/server/src/provider/Layers/codexLaunchArgs.test.ts created+59
......@@ -0,0 +1,59 @@
1import * as NodeAssert from "node:assert/strict";
2
3import { describe, it } from "vite-plus/test";
4
5import {
6 codexAppServerArgs,
7 codexExecLaunchArgs,
8 resolveCodexLaunchArgs,
9} from "./codexLaunchArgs.ts";
10
11describe("resolveCodexLaunchArgs", () => {
12 it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => {
13 NodeAssert.equal(
14 resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }),
15 "--enable foo",
16 );
17 });
18
19 it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => {
20 NodeAssert.equal(
21 resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }),
22 "--strict-config",
23 );
24 });
25
26 it("ignores whitespace-only environment values", () => {
27 NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), "");
28 });
29});
30
31describe("codexAppServerArgs", () => {
32 it("returns the app-server command for empty launch args", () => {
33 NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]);
34 });
35
36 it("appends parsed launch args after app-server", () => {
37 NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [
38 "app-server",
39 "--strict-config",
40 "--enable",
41 "foo",
42 ]);
43 });
44});
45
46describe("codexExecLaunchArgs", () => {
47 it("keeps shared codex flags and omits app-server-only flags", () => {
48 NodeAssert.deepStrictEqual(
49 codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'),
50 ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"],
51 );
52 });
53
54 it("does not pair value-taking flags with adjacent flags", () => {
55 NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [
56 "--strict-config",
57 ]);
58 });
59});
apps/server/src/provider/Layers/codexLaunchArgs.ts created+48
......@@ -0,0 +1,48 @@
1import { tokenizeCliArgs } from "@t3tools/shared/cliArgs";
2
3export const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS";
4
5export const resolveCodexLaunchArgs = (
6 launchArgs?: string,
7 environment: NodeJS.ProcessEnv = process.env,
8) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || "";
9
10export const codexLaunchArgv = (launchArgs?: string): ReadonlyArray<string> =>
11 tokenizeCliArgs(launchArgs);
12
13export const codexAppServerArgs = (launchArgs?: string) => [
14 "app-server",
15 ...codexLaunchArgv(launchArgs),
16];
17
18export const codexExecLaunchArgs = (launchArgs?: string) => {
19 const args = codexLaunchArgv(launchArgs);
20 const execArgs: Array<string> = [];
21
22 for (let index = 0; index < args.length; index++) {
23 const arg = args[index];
24 if (arg === undefined) continue;
25
26 if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) {
27 execArgs.push(arg);
28 } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") {
29 const value = args[index + 1];
30 if (value !== undefined && !value.startsWith("-")) {
31 execArgs.push(arg, value);
32 index++;
33 }
34 } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) {
35 execArgs.push(arg);
36 }
37 }
38
39 return execArgs;
40};
41
42export const codexSessionAppServerArgs = (
43 appServerArgs: ReadonlyArray<string> | undefined,
44 launchArgs: string | undefined,
45) => {
46 const launchAppServerArgs = codexAppServerArgs(launchArgs);
47 return appServerArgs ? [...launchAppServerArgs, ...appServerArgs] : launchAppServerArgs;
48};
apps/server/src/serverSettings.test.ts+2
......@@ -179,6 +179,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
179179 binaryPath: "/opt/homebrew/bin/codex",
180180 homePath: "/Users/julius/.codex",
181181 shadowHomePath: "",
182 launchArgs: "",
182183 customModels: [],
183184 });
184185 assert.deepEqual(next.providers.claudeAgent, {
......@@ -420,6 +421,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
420421 binaryPath: "/opt/homebrew/bin/codex",
421422 homePath: "",
422423 shadowHomePath: "",
424 launchArgs: "",
423425 customModels: [],
424426 });
425427 assert.deepEqual(next.providers.claudeAgent, {
apps/server/src/textGeneration/CodexTextGeneration.test.ts+70-2
......@@ -35,6 +35,8 @@ function makeFakeCodexBinary(
3535 requireServiceTier?: string;
3636 requireReasoningEffort?: string;
3737 forbidReasoningEffort?: boolean;
38 requireArg?: string;
39 forbidArg?: string;
3840 stdinMustContain?: string;
3941 stdinMustNotContain?: string;
4042 },
......@@ -50,6 +52,7 @@ function makeFakeCodexBinary(
5052 codexPath,
5153 [
5254 "#!/bin/sh",
55 'original_args="$*"',
5356 'output_path=""',
5457 'seen_image="0"',
5558 'seen_service_tier=""',
......@@ -87,6 +90,22 @@ function makeFakeCodexBinary(
8790 " shift",
8891 "done",
8992 'stdin_content="$(cat)"',
93 ...(input.requireArg !== undefined
94 ? [
95 `case " $original_args " in *" ${input.requireArg} "*) ;; *)`,
96 ` printf "%s\\n" "missing arg: ${input.requireArg}" >&2`,
97 ` exit 8`,
98 "esac",
99 ]
100 : []),
101 ...(input.forbidArg !== undefined
102 ? [
103 `case " $original_args " in *" ${input.forbidArg} "*)`,
104 ` printf "%s\\n" "forbidden arg: ${input.forbidArg}" >&2`,
105 ` exit 9`,
106 "esac",
107 ]
108 : []),
90109 ...(input.requireImage
91110 ? [
92111 'if [ "$seen_image" != "1" ]; then',
......@@ -166,8 +185,12 @@ function withFakeCodexEnv<A, E, R>(
166185 requireServiceTier?: string;
167186 requireReasoningEffort?: string;
168187 forbidReasoningEffort?: boolean;
188 requireArg?: string;
189 forbidArg?: string;
169190 stdinMustContain?: string;
170191 stdinMustNotContain?: string;
192 launchArgs?: string;
193 environment?: NodeJS.ProcessEnv;
171194 },
172195 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
173196) {
......@@ -175,8 +198,8 @@ function withFakeCodexEnv<A, E, R>(
175198 const fs = yield* FileSystem.FileSystem;
176199 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
177200 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
178 const config = decodeCodexSettings({ binaryPath: codexPath });
179 const textGeneration = yield* makeCodexTextGeneration(config);
201 const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs });
202 const textGeneration = yield* makeCodexTextGeneration(config, input.environment);
180203 return yield* effectFn(textGeneration);
181204 }).pipe(Effect.scoped);
182205}
......@@ -237,6 +260,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
237260 ),
238261 );
239262
263 it.effect("passes exec-safe launch args into codex exec", () =>
264 withFakeCodexEnv(
265 {
266 output: JSON.stringify({
267 subject: "Add important change",
268 body: "",
269 }),
270 launchArgs: "--strict-config --listen off",
271 requireArg: "--strict-config",
272 forbidArg: "--listen",
273 },
274 (textGeneration) =>
275 textGeneration.generateCommitMessage({
276 cwd: process.cwd(),
277 branch: "feature/codex-effect",
278 stagedSummary: "M README.md",
279 stagedPatch: "diff --git a/README.md b/README.md",
280 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
281 }),
282 ),
283 );
284
285 it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for codex exec over settings", () =>
286 withFakeCodexEnv(
287 {
288 output: JSON.stringify({
289 subject: "Add important change",
290 body: "",
291 }),
292 launchArgs: "--enable settings-feature",
293 environment: { T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --listen off " },
294 requireArg: "--strict-config",
295 forbidArg: "settings-feature",
296 },
297 (textGeneration) =>
298 textGeneration.generateCommitMessage({
299 cwd: process.cwd(),
300 branch: "feature/codex-effect",
301 stagedSummary: "M README.md",
302 stagedPatch: "diff --git a/README.md b/README.md",
303 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
304 }),
305 ),
306 );
307
240308 it.effect("defaults git text generation codex effort to low", () =>
241309 withFakeCodexEnv(
242310 {
apps/server/src/textGeneration/CodexTextGeneration.ts+3
......@@ -14,6 +14,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
1414import { resolveAttachmentPath } from "../attachmentStore.ts";
1515import * as ServerConfig from "../config.ts";
1616import { expandHomePath } from "../pathExpansion.ts";
17import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts";
1718import { TextGenerationError } from "@t3tools/contracts";
1819import * as TextGeneration from "./TextGeneration.ts";
1920import {
......@@ -174,6 +175,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
174175 const outputPath = yield* writeTempFile(operation, "codex-output", "");
175176
176177 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
178 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);
177179 const reasoningEffort =
178180 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
179181 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
......@@ -182,6 +184,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
182184 codexConfig.binaryPath || "codex",
183185 [
184186 "exec",
187 ...codexExecLaunchArgs(launchArgs),
185188 "--ephemeral",
186189 "--skip-git-repo-check",
187190 "-s",
apps/web/src/components/settings/ProviderSettingsForm.test.ts+1
......@@ -18,6 +18,7 @@ describe("ProviderSettingsForm helpers", () => {
1818 "binaryPath",
1919 "homePath",
2020 "shadowHomePath",
21 "launchArgs",
2122 ]);
2223 });
2324
packages/contracts/src/settings.test.ts+4
......@@ -141,6 +141,7 @@ describe("ServerSettingsPatch string normalization", () => {
141141 codex: {
142142 binaryPath: " /opt/homebrew/bin/codex ",
143143 homePath: " ~/.codex ",
144 launchArgs: " --strict-config --enable foo ",
144145 },
145146 },
146147 providerInstances: {
......@@ -157,6 +158,7 @@ describe("ServerSettingsPatch string normalization", () => {
157158 expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
158159 expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
159160 expect(patch.providers?.codex?.homePath).toBe("~/.codex");
161 expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo");
160162 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(
161163 "codex",
162164 );
......@@ -178,11 +180,13 @@ describe("ServerSettingsPatch string normalization", () => {
178180 codex: {
179181 ...defaultSettings.providers.codex,
180182 binaryPath: " /opt/homebrew/bin/codex ",
183 launchArgs: " --strict-config ",
181184 },
182185 },
183186 });
184187
185188 expect(encoded.addProjectBaseDirectory).toBe("~/Development");
186189 expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
190 expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config");
187191 });
188192});
packages/contracts/src/settings.ts+9-1
......@@ -191,13 +191,20 @@ export const CodexSettings = makeProviderSettingsSchema(
191191 },
192192 }),
193193 ),
194 launchArgs: TrimmedString.pipe(
195 Schema.withDecodingDefault(Effect.succeed("")),
196 Schema.annotateKey({
197 title: "Launch arguments",
198 description: "Additional CLI arguments passed to codex app-server on session start.",
199 }),
200 ),
194201 customModels: Schema.Array(Schema.String).pipe(
195202 Schema.withDecodingDefault(Effect.succeed([])),
196203 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
197204 ),
198205 },
199206 {
200 order: ["binaryPath", "homePath", "shadowHomePath"],
207 order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"],
201208 },
202209);
203210export type CodexSettings = typeof CodexSettings.Type;
......@@ -469,6 +476,7 @@ const CodexSettingsPatch = Schema.Struct({
469476 binaryPath: Schema.optionalKey(TrimmedString),
470477 homePath: Schema.optionalKey(TrimmedString),
471478 shadowHomePath: Schema.optionalKey(TrimmedString),
479 launchArgs: Schema.optionalKey(TrimmedString),
472480 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
473481});
474482
packages/shared/src/cliArgs.test.ts+29-1
......@@ -1,6 +1,27 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { parseCliArgs } from "./cliArgs.ts";
3import { parseCliArgs, tokenizeCliArgs } from "./cliArgs.ts";
4
5describe("tokenizeCliArgs", () => {
6 it("preserves quoted values and escaped spaces", () => {
7 expect(
8 tokenizeCliArgs(
9 String.raw`--config model="gpt 5" --enable foo\ bar --config=profile='work profile'`,
10 ),
11 ).toEqual(["--config", "model=gpt 5", "--enable", "foo bar", "--config=profile=work profile"]);
12 });
13
14 it("preserves literal backslashes in path values", () => {
15 expect(
16 tokenizeCliArgs(String.raw`--config cacheDir=C:\Users\me --config "quoted=C:\Users\me"`),
17 ).toEqual([
18 "--config",
19 String.raw`cacheDir=C:\Users\me`,
20 "--config",
21 String.raw`quoted=C:\Users\me`,
22 ]);
23 });
24});
425
526describe("parseCliArgs", () => {
627 it("returns empty result for empty string", () => {
......@@ -57,6 +78,13 @@ describe("parseCliArgs", () => {
5778 });
5879 });
5980
81 it("parses quoted --append-system-prompt with value and --chrome", () => {
82 expect(parseCliArgs(`--append-system-prompt "always think step by step" --chrome`)).toEqual({
83 flags: { "append-system-prompt": "always think step by step", chrome: null },
84 positionals: [],
85 });
86 });
87
6088 it("parses --max-budget-usd with numeric value", () => {
6189 expect(parseCliArgs("--chrome --max-budget-usd 5.00")).toEqual({
6290 flags: { chrome: null, "max-budget-usd": "5.00" },
packages/shared/src/cliArgs.ts+59-3
......@@ -7,10 +7,67 @@ export interface ParseCliArgsOptions {
77 readonly booleanFlags?: readonly string[];
88}
99
10export function tokenizeCliArgs(args?: string): ReadonlyArray<string> {
11 const input = args?.trim();
12 if (!input) return [];
13
14 const tokens: string[] = [];
15 let current = "";
16 let quote: "'" | '"' | undefined;
17 let quoted = false;
18
19 for (let index = 0; index < input.length; index++) {
20 const char = input[index];
21 if (char === undefined) continue;
22
23 if (quote) {
24 if (char === quote) {
25 quote = undefined;
26 quoted = true;
27 } else if (char === "\\" && quote === '"') {
28 const next = input[index + 1];
29 if (next !== undefined && ['"', "\\", "$", "`"].includes(next)) {
30 current += next;
31 index++;
32 } else {
33 current += char;
34 }
35 } else {
36 current += char;
37 }
38 continue;
39 }
40
41 if (char === "'" || char === '"') {
42 quote = char;
43 quoted = true;
44 } else if (/\s/.test(char)) {
45 if (current || quoted) {
46 tokens.push(current);
47 current = "";
48 quoted = false;
49 }
50 } else if (char === "\\") {
51 const next = input[index + 1];
52 if (next !== undefined && /\s/.test(next)) {
53 current += next;
54 index++;
55 } else {
56 current += char;
57 }
58 } else {
59 current += char;
60 }
61 }
62
63 if (current || quoted) tokens.push(current);
64 return tokens;
65}
66
1067/**
1168 * Parse CLI-style arguments into flags and positionals.
1269 *
13 * Accepts a string (split by whitespace) or a pre-split argv array.
70 * Accepts a string (quote-aware tokenized) or a pre-split argv array.
1471 * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax.
1572 *
1673 * parseCliArgs("")
......@@ -32,8 +89,7 @@ export function parseCliArgs(
3289 args: string | readonly string[],
3390 options?: ParseCliArgsOptions,
3491): ParsedCliArgs {
35 const tokens =
36 typeof args === "string" ? args.trim().split(/\s+/).filter(Boolean) : Array.from(args);
92 const tokens = typeof args === "string" ? tokenizeCliArgs(args) : Array.from(args);
3793 const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined;
3894
3995 const flags: Record<string, string | null> = {};