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) => {...@@ -279,6 +279,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
279 NodeAssert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], {279 NodeAssert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], {
280 binaryPath: "codex",280 binaryPath: "codex",
281 cwd: process.cwd(),281 cwd: process.cwd(),
282 launchArgs: "",
282 model: "gpt-5.3-codex",283 model: "gpt-5.3-codex",
283 providerInstanceId: ProviderInstanceId.make("codex"),284 providerInstanceId: ProviderInstanceId.make("codex"),
284 serviceTier: "priority",285 serviceTier: "priority",
...@@ -359,6 +360,69 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -359,6 +360,69 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
359 }),360 }),
360 );361 );
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
362 it.effect("maps codex model options for the adapter's bound custom instance id", () => {426 it.effect("maps codex model options for the adapter's bound custom instance id", () => {
363 const customInstanceId = ProviderInstanceId.make("codex_personal");427 const customInstanceId = ProviderInstanceId.make("codex_personal");
364 const customRuntimeFactory = makeRuntimeFactory();428 const customRuntimeFactory = makeRuntimeFactory();
apps/server/src/provider/Layers/CodexAdapter.ts+2
...@@ -61,6 +61,7 @@ import {...@@ -61,6 +61,7 @@ import {
61 type CodexSessionRuntimeShape,61 type CodexSessionRuntimeShape,
62} from "./CodexSessionRuntime.ts";62} from "./CodexSessionRuntime.ts";
63import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";63import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
64import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
64const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);65const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);
65const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);66const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);
66const isCodexSessionRuntimeThreadIdMissingError = Schema.is(67const isCodexSessionRuntimeThreadIdMissingError = Schema.is(
...@@ -1399,6 +1400,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -1399,6 +1400,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
1399 providerInstanceId: boundInstanceId,1400 providerInstanceId: boundInstanceId,
1400 cwd: input.cwd ?? process.cwd(),1401 cwd: input.cwd ?? process.cwd(),
1401 binaryPath: codexConfig.binaryPath,1402 binaryPath: codexConfig.binaryPath,
1403 launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment),
1402 ...(options?.environment ? { environment: options.environment } : {}),1404 ...(options?.environment ? { environment: options.environment } : {}),
1403 ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),1405 ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),
1404 ...(isCodexResumeCursorSchema(input.resumeCursor)1406 ...(isCodexResumeCursorSchema(input.resumeCursor)
apps/server/src/provider/Layers/CodexProvider.ts+12-4
...@@ -26,6 +26,7 @@ import { ServerSettingsError } from "@t3tools/contracts";...@@ -26,6 +26,7 @@ import { ServerSettingsError } from "@t3tools/contracts";
2626
27import { createModelCapabilities } from "@t3tools/shared/model";27import { createModelCapabilities } from "@t3tools/shared/model";
28import { resolveSpawnCommand } from "@t3tools/shared/shell";28import { resolveSpawnCommand } from "@t3tools/shared/shell";
29import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
29import {30import {
30 AUTH_PROBE_TIMEOUT_MS,31 AUTH_PROBE_TIMEOUT_MS,
31 buildServerProvider,32 buildServerProvider,
...@@ -289,6 +290,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {...@@ -289,6 +290,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
289const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {290const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
290 readonly binaryPath: string;291 readonly binaryPath: string;
291 readonly homePath?: string;292 readonly homePath?: string;
293 readonly launchArgs?: string;
292 readonly cwd: string;294 readonly cwd: string;
293 readonly customModels?: ReadonlyArray<string>;295 readonly customModels?: ReadonlyArray<string>;
294 readonly environment?: NodeJS.ProcessEnv;296 readonly environment?: NodeJS.ProcessEnv;
...@@ -303,10 +305,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -303,10 +305,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
303 ...input.environment,305 ...input.environment,
304 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),306 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
305 };307 };
306 const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], {308 const spawnCommand = yield* resolveSpawnCommand(
307 env: environment,309 input.binaryPath,
308 extendEnv: true,310 codexAppServerArgs(input.launchArgs),
309 });311 {
312 env: environment,
313 extendEnv: true,
314 },
315 );
310 const child = yield* spawner316 const child = yield* spawner
311 .spawn(317 .spawn(
312 ChildProcess.make(spawnCommand.command, spawnCommand.args, {318 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
...@@ -465,6 +471,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -465,6 +471,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
465 probe: (input: {471 probe: (input: {
466 readonly binaryPath: string;472 readonly binaryPath: string;
467 readonly homePath?: string;473 readonly homePath?: string;
474 readonly launchArgs?: string;
468 readonly cwd: string;475 readonly cwd: string;
469 readonly customModels: ReadonlyArray<string>;476 readonly customModels: ReadonlyArray<string>;
470 readonly environment?: NodeJS.ProcessEnv;477 readonly environment?: NodeJS.ProcessEnv;
...@@ -503,6 +510,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -503,6 +510,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
503 const probeResult = yield* probe({510 const probeResult = yield* probe({
504 binaryPath: codexSettings.binaryPath,511 binaryPath: codexSettings.binaryPath,
505 homePath: codexSettings.homePath,512 homePath: codexSettings.homePath,
513 launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment),
506 cwd: process.cwd(),514 cwd: process.cwd(),
507 customModels: codexSettings.customModels,515 customModels: codexSettings.customModels,
508 environment: resolvedEnvironment,516 environment: resolvedEnvironment,
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+28
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,13 CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
14 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,14 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
15} from "../CodexDeveloperInstructions.ts";15} from "../CodexDeveloperInstructions.ts";
16import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
16import {17import {
17 buildTurnStartParams,18 buildTurnStartParams,
18 hasConfiguredMcpServer,19 hasConfiguredMcpServer,
...@@ -289,6 +290,33 @@ describe("hasConfiguredMcpServer", () => {...@@ -289,6 +290,33 @@ describe("hasConfiguredMcpServer", () => {
289 });290 });
290});291});
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
292describe("isRecoverableThreadResumeError", () => {320describe("isRecoverableThreadResumeError", () => {
293 it("matches missing thread errors", () => {321 it("matches missing thread errors", () => {
294 NodeAssert.equal(322 NodeAssert.equal(
apps/server/src/provider/Layers/CodexSessionRuntime.ts+7-5
...@@ -36,6 +36,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc";...@@ -36,6 +36,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc";
36import * as EffectCodexSchema from "effect-codex-app-server/schema";36import * as EffectCodexSchema from "effect-codex-app-server/schema";
3737
38import { buildCodexInitializeParams } from "./CodexProvider.ts";38import { buildCodexInitializeParams } from "./CodexProvider.ts";
39import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
39import { expandHomePath } from "../../pathExpansion.ts";40import { expandHomePath } from "../../pathExpansion.ts";
40import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts";41import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts";
41const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse);42const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse);
...@@ -97,6 +98,7 @@ export interface CodexSessionRuntimeOptions {...@@ -97,6 +98,7 @@ export interface CodexSessionRuntimeOptions {
97 readonly providerInstanceId?: ProviderInstanceId;98 readonly providerInstanceId?: ProviderInstanceId;
98 readonly binaryPath: string;99 readonly binaryPath: string;
99 readonly homePath?: string;100 readonly homePath?: string;
101 readonly launchArgs?: string;
100 readonly environment?: NodeJS.ProcessEnv;102 readonly environment?: NodeJS.ProcessEnv;
101 readonly cwd: string;103 readonly cwd: string;
102 readonly runtimeMode: RuntimeMode;104 readonly runtimeMode: RuntimeMode;
...@@ -717,11 +719,11 @@ export const makeCodexSessionRuntime = (...@@ -717,11 +719,11 @@ export const makeCodexSessionRuntime = (
717 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),719 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
718 };720 };
719 const extendEnv = options.environment === undefined;721 const extendEnv = options.environment === undefined;
720 const spawnCommand = yield* resolveSpawnCommand(722 const appServerArgs = codexSessionAppServerArgs(options.appServerArgs, options.launchArgs);
721 options.binaryPath,723 const spawnCommand = yield* resolveSpawnCommand(options.binaryPath, appServerArgs, {
722 ["app-server", ...(options.appServerArgs ?? [])],724 env,
723 { env, extendEnv },725 extendEnv,
724 );726 });
725 const child = yield* spawner727 const child = yield* spawner
726 .spawn(728 .spawn(
727 ChildProcess.make(spawnCommand.command, spawnCommand.args, {729 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 => ({...@@ -61,6 +61,7 @@ const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
61 binaryPath: "codex",61 binaryPath: "codex",
62 homePath: "",62 homePath: "",
63 shadowHomePath: "",63 shadowHomePath: "",
64 launchArgs: "",
64 customModels: [],65 customModels: [],
65 ...overrides,66 ...overrides,
66});67});
apps/server/src/provider/Layers/ProviderRegistry.test.ts+16
...@@ -55,6 +55,7 @@ const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTING...@@ -55,6 +55,7 @@ const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTING
5555
56const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});56const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
57const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});57const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
58const decodeCodexSettings = Schema.decodeSync(CodexSettings);
58const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({59const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({
59 enabled: false,60 enabled: false,
60});61});
...@@ -348,6 +349,21 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -348,6 +349,21 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
348 }),349 }),
349 );350 );
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
351 it.effect("returns unauthenticated when app-server requires OpenAI auth", () =>367 it.effect("returns unauthenticated when app-server requires OpenAI auth", () =>
352 Effect.gen(function* () {368 Effect.gen(function* () {
353 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>369 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) => {...@@ -179,6 +179,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
179 binaryPath: "/opt/homebrew/bin/codex",179 binaryPath: "/opt/homebrew/bin/codex",
180 homePath: "/Users/julius/.codex",180 homePath: "/Users/julius/.codex",
181 shadowHomePath: "",181 shadowHomePath: "",
182 launchArgs: "",
182 customModels: [],183 customModels: [],
183 });184 });
184 assert.deepEqual(next.providers.claudeAgent, {185 assert.deepEqual(next.providers.claudeAgent, {
...@@ -420,6 +421,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -420,6 +421,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
420 binaryPath: "/opt/homebrew/bin/codex",421 binaryPath: "/opt/homebrew/bin/codex",
421 homePath: "",422 homePath: "",
422 shadowHomePath: "",423 shadowHomePath: "",
424 launchArgs: "",
423 customModels: [],425 customModels: [],
424 });426 });
425 assert.deepEqual(next.providers.claudeAgent, {427 assert.deepEqual(next.providers.claudeAgent, {
apps/server/src/textGeneration/CodexTextGeneration.test.ts+70-2
...@@ -35,6 +35,8 @@ function makeFakeCodexBinary(...@@ -35,6 +35,8 @@ function makeFakeCodexBinary(
35 requireServiceTier?: string;35 requireServiceTier?: string;
36 requireReasoningEffort?: string;36 requireReasoningEffort?: string;
37 forbidReasoningEffort?: boolean;37 forbidReasoningEffort?: boolean;
38 requireArg?: string;
39 forbidArg?: string;
38 stdinMustContain?: string;40 stdinMustContain?: string;
39 stdinMustNotContain?: string;41 stdinMustNotContain?: string;
40 },42 },
...@@ -50,6 +52,7 @@ function makeFakeCodexBinary(...@@ -50,6 +52,7 @@ function makeFakeCodexBinary(
50 codexPath,52 codexPath,
51 [53 [
52 "#!/bin/sh",54 "#!/bin/sh",
55 'original_args="$*"',
53 'output_path=""',56 'output_path=""',
54 'seen_image="0"',57 'seen_image="0"',
55 'seen_service_tier=""',58 'seen_service_tier=""',
...@@ -87,6 +90,22 @@ function makeFakeCodexBinary(...@@ -87,6 +90,22 @@ function makeFakeCodexBinary(
87 " shift",90 " shift",
88 "done",91 "done",
89 'stdin_content="$(cat)"',92 '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 : []),
90 ...(input.requireImage109 ...(input.requireImage
91 ? [110 ? [
92 'if [ "$seen_image" != "1" ]; then',111 'if [ "$seen_image" != "1" ]; then',
...@@ -166,8 +185,12 @@ function withFakeCodexEnv<A, E, R>(...@@ -166,8 +185,12 @@ function withFakeCodexEnv<A, E, R>(
166 requireServiceTier?: string;185 requireServiceTier?: string;
167 requireReasoningEffort?: string;186 requireReasoningEffort?: string;
168 forbidReasoningEffort?: boolean;187 forbidReasoningEffort?: boolean;
188 requireArg?: string;
189 forbidArg?: string;
169 stdinMustContain?: string;190 stdinMustContain?: string;
170 stdinMustNotContain?: string;191 stdinMustNotContain?: string;
192 launchArgs?: string;
193 environment?: NodeJS.ProcessEnv;
171 },194 },
172 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,195 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
173) {196) {
...@@ -175,8 +198,8 @@ function withFakeCodexEnv<A, E, R>(...@@ -175,8 +198,8 @@ function withFakeCodexEnv<A, E, R>(
175 const fs = yield* FileSystem.FileSystem;198 const fs = yield* FileSystem.FileSystem;
176 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });199 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
177 const codexPath = yield* makeFakeCodexBinary(tempDir, input);200 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
178 const config = decodeCodexSettings({ binaryPath: codexPath });201 const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs });
179 const textGeneration = yield* makeCodexTextGeneration(config);202 const textGeneration = yield* makeCodexTextGeneration(config, input.environment);
180 return yield* effectFn(textGeneration);203 return yield* effectFn(textGeneration);
181 }).pipe(Effect.scoped);204 }).pipe(Effect.scoped);
182}205}
...@@ -237,6 +260,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {...@@ -237,6 +260,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
237 ),260 ),
238 );261 );
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
240 it.effect("defaults git text generation codex effort to low", () =>308 it.effect("defaults git text generation codex effort to low", () =>
241 withFakeCodexEnv(309 withFakeCodexEnv(
242 {310 {
apps/server/src/textGeneration/CodexTextGeneration.ts+3
...@@ -14,6 +14,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";...@@ -14,6 +14,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
14import { resolveAttachmentPath } from "../attachmentStore.ts";14import { resolveAttachmentPath } from "../attachmentStore.ts";
15import * as ServerConfig from "../config.ts";15import * as ServerConfig from "../config.ts";
16import { expandHomePath } from "../pathExpansion.ts";16import { expandHomePath } from "../pathExpansion.ts";
17import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts";
17import { TextGenerationError } from "@t3tools/contracts";18import { TextGenerationError } from "@t3tools/contracts";
18import * as TextGeneration from "./TextGeneration.ts";19import * as TextGeneration from "./TextGeneration.ts";
19import {20import {
...@@ -174,6 +175,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -174,6 +175,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
174 const outputPath = yield* writeTempFile(operation, "codex-output", "");175 const outputPath = yield* writeTempFile(operation, "codex-output", "");
175176
176 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {177 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
178 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);
177 const reasoningEffort =179 const reasoningEffort =
178 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??180 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
179 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;181 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
...@@ -182,6 +184,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -182,6 +184,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
182 codexConfig.binaryPath || "codex",184 codexConfig.binaryPath || "codex",
183 [185 [
184 "exec",186 "exec",
187 ...codexExecLaunchArgs(launchArgs),
185 "--ephemeral",188 "--ephemeral",
186 "--skip-git-repo-check",189 "--skip-git-repo-check",
187 "-s",190 "-s",
apps/web/src/components/settings/ProviderSettingsForm.test.ts+1
...@@ -18,6 +18,7 @@ describe("ProviderSettingsForm helpers", () => {...@@ -18,6 +18,7 @@ describe("ProviderSettingsForm helpers", () => {
18 "binaryPath",18 "binaryPath",
19 "homePath",19 "homePath",
20 "shadowHomePath",20 "shadowHomePath",
21 "launchArgs",
21 ]);22 ]);
22 });23 });
2324
packages/contracts/src/settings.test.ts+4
...@@ -141,6 +141,7 @@ describe("ServerSettingsPatch string normalization", () => {...@@ -141,6 +141,7 @@ describe("ServerSettingsPatch string normalization", () => {
141 codex: {141 codex: {
142 binaryPath: " /opt/homebrew/bin/codex ",142 binaryPath: " /opt/homebrew/bin/codex ",
143 homePath: " ~/.codex ",143 homePath: " ~/.codex ",
144 launchArgs: " --strict-config --enable foo ",
144 },145 },
145 },146 },
146 providerInstances: {147 providerInstances: {
...@@ -157,6 +158,7 @@ describe("ServerSettingsPatch string normalization", () => {...@@ -157,6 +158,7 @@ describe("ServerSettingsPatch string normalization", () => {
157 expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");158 expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
158 expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");159 expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
159 expect(patch.providers?.codex?.homePath).toBe("~/.codex");160 expect(patch.providers?.codex?.homePath).toBe("~/.codex");
161 expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo");
160 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(162 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(
161 "codex",163 "codex",
162 );164 );
...@@ -178,11 +180,13 @@ describe("ServerSettingsPatch string normalization", () => {...@@ -178,11 +180,13 @@ describe("ServerSettingsPatch string normalization", () => {
178 codex: {180 codex: {
179 ...defaultSettings.providers.codex,181 ...defaultSettings.providers.codex,
180 binaryPath: " /opt/homebrew/bin/codex ",182 binaryPath: " /opt/homebrew/bin/codex ",
183 launchArgs: " --strict-config ",
181 },184 },
182 },185 },
183 });186 });
184187
185 expect(encoded.addProjectBaseDirectory).toBe("~/Development");188 expect(encoded.addProjectBaseDirectory).toBe("~/Development");
186 expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");189 expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
190 expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config");
187 });191 });
188});192});
packages/contracts/src/settings.ts+9-1
...@@ -191,13 +191,20 @@ export const CodexSettings = makeProviderSettingsSchema(...@@ -191,13 +191,20 @@ export const CodexSettings = makeProviderSettingsSchema(
191 },191 },
192 }),192 }),
193 ),193 ),
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 ),
194 customModels: Schema.Array(Schema.String).pipe(201 customModels: Schema.Array(Schema.String).pipe(
195 Schema.withDecodingDefault(Effect.succeed([])),202 Schema.withDecodingDefault(Effect.succeed([])),
196 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),203 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
197 ),204 ),
198 },205 },
199 {206 {
200 order: ["binaryPath", "homePath", "shadowHomePath"],207 order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"],
201 },208 },
202);209);
203export type CodexSettings = typeof CodexSettings.Type;210export type CodexSettings = typeof CodexSettings.Type;
...@@ -469,6 +476,7 @@ const CodexSettingsPatch = Schema.Struct({...@@ -469,6 +476,7 @@ const CodexSettingsPatch = Schema.Struct({
469 binaryPath: Schema.optionalKey(TrimmedString),476 binaryPath: Schema.optionalKey(TrimmedString),
470 homePath: Schema.optionalKey(TrimmedString),477 homePath: Schema.optionalKey(TrimmedString),
471 shadowHomePath: Schema.optionalKey(TrimmedString),478 shadowHomePath: Schema.optionalKey(TrimmedString),
479 launchArgs: Schema.optionalKey(TrimmedString),
472 customModels: Schema.optionalKey(Schema.Array(Schema.String)),480 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
473});481});
474482
packages/shared/src/cliArgs.test.ts+29-1
...@@ -1,6 +1,27 @@...@@ -1,6 +1,27 @@
1import { describe, expect, it } from "vite-plus/test";1import { 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
5describe("parseCliArgs", () => {26describe("parseCliArgs", () => {
6 it("returns empty result for empty string", () => {27 it("returns empty result for empty string", () => {
...@@ -57,6 +78,13 @@ describe("parseCliArgs", () => {...@@ -57,6 +78,13 @@ describe("parseCliArgs", () => {
57 });78 });
58 });79 });
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
60 it("parses --max-budget-usd with numeric value", () => {88 it("parses --max-budget-usd with numeric value", () => {
61 expect(parseCliArgs("--chrome --max-budget-usd 5.00")).toEqual({89 expect(parseCliArgs("--chrome --max-budget-usd 5.00")).toEqual({
62 flags: { chrome: null, "max-budget-usd": "5.00" },90 flags: { chrome: null, "max-budget-usd": "5.00" },
packages/shared/src/cliArgs.ts+59-3
...@@ -7,10 +7,67 @@ export interface ParseCliArgsOptions {...@@ -7,10 +7,67 @@ export interface ParseCliArgsOptions {
7 readonly booleanFlags?: readonly string[];7 readonly booleanFlags?: readonly string[];
8}8}
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
10/**67/**
11 * Parse CLI-style arguments into flags and positionals.68 * Parse CLI-style arguments into flags and positionals.
12 *69 *
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.
14 * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax.71 * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax.
15 *72 *
16 * parseCliArgs("")73 * parseCliArgs("")
...@@ -32,8 +89,7 @@ export function parseCliArgs(...@@ -32,8 +89,7 @@ export function parseCliArgs(
32 args: string | readonly string[],89 args: string | readonly string[],
33 options?: ParseCliArgsOptions,90 options?: ParseCliArgsOptions,
34): ParsedCliArgs {91): ParsedCliArgs {
35 const tokens =92 const tokens = typeof args === "string" ? tokenizeCliArgs(args) : Array.from(args);
36 typeof args === "string" ? args.trim().split(/\s+/).filter(Boolean) : Array.from(args);
37 const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined;93 const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined;
3894
39 const flags: Record<string, string | null> = {};95 const flags: Record<string, string | null> = {};