| author | |
| committer | |
| log | 40c0ab088308fb010a3db488c545f10aa7179ed2 |
| tree | eedd0d470ab9e5592e594972988d5ad9e6b6db67 |
| parent | 33f1cb422461bf0f072b9f181734e9b654d1ca69 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 361 | 362 | ||
| 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"; |
| 63 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 63 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 64 | import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; | ||
| 64 | const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); | 65 | const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); |
| 65 | const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); | 66 | const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); |
| 66 | const isCodexSessionRuntimeThreadIdMissingError = Schema.is( | 67 | const 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"; |
| 26 | 26 | ||
| 27 | import { createModelCapabilities } from "@t3tools/shared/model"; | 27 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 28 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 28 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 29 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; | ||
| 29 | import { | 30 | import { |
| 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 { |
| 289 | const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { | 290 | const 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* spawner | 316 | 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"; |
| 16 | import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; | ||
| 16 | import { | 17 | import { |
| 17 | buildTurnStartParams, | 18 | buildTurnStartParams, |
| 18 | hasConfiguredMcpServer, | 19 | hasConfiguredMcpServer, |
| ... | @@ -289,6 +290,33 @@ describe("hasConfiguredMcpServer", () => { | ... | @@ -289,6 +290,33 @@ describe("hasConfiguredMcpServer", () => { |
| 289 | }); | 290 | }); |
| 290 | }); | 291 | }); |
| 291 | 292 | ||
| 293 | describe("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 | |||
| 292 | describe("isRecoverableThreadResumeError", () => { | 320 | describe("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"; |
| 36 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; | 36 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| 37 | 37 | ||
| 38 | import { buildCodexInitializeParams } from "./CodexProvider.ts"; | 38 | import { buildCodexInitializeParams } from "./CodexProvider.ts"; |
| 39 | import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; | ||
| 39 | import { expandHomePath } from "../../pathExpansion.ts"; | 40 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 40 | import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; | 41 | import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; |
| 41 | const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); | 42 | const 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* spawner | 727 | 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 |
| 55 | 55 | ||
| 56 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); | 56 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); |
| 57 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); | 57 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); |
| 58 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); | ||
| 58 | const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ | 59 | const 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 | ); |
| 350 | 351 | ||
| 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 @@ | ||
| 1 | import * as NodeAssert from "node:assert/strict"; | ||
| 2 | |||
| 3 | import { describe, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | codexAppServerArgs, | ||
| 7 | codexExecLaunchArgs, | ||
| 8 | resolveCodexLaunchArgs, | ||
| 9 | } from "./codexLaunchArgs.ts"; | ||
| 10 | |||
| 11 | describe("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 | |||
| 31 | describe("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 | |||
| 46 | describe("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 @@ | ||
| 1 | import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; | ||
| 2 | |||
| 3 | export const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; | ||
| 4 | |||
| 5 | export const resolveCodexLaunchArgs = ( | ||
| 6 | launchArgs?: string, | ||
| 7 | environment: NodeJS.ProcessEnv = process.env, | ||
| 8 | ) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; | ||
| 9 | |||
| 10 | export const codexLaunchArgv = (launchArgs?: string): ReadonlyArray<string> => | ||
| 11 | tokenizeCliArgs(launchArgs); | ||
| 12 | |||
| 13 | export const codexAppServerArgs = (launchArgs?: string) => [ | ||
| 14 | "app-server", | ||
| 15 | ...codexLaunchArgv(launchArgs), | ||
| 16 | ]; | ||
| 17 | |||
| 18 | export 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 | |||
| 42 | export 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.requireImage | 109 | ...(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 | ); |
| 239 | 262 | ||
| 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"; |
| 14 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | 14 | import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| 15 | import * as ServerConfig from "../config.ts"; | 15 | import * as ServerConfig from "../config.ts"; |
| 16 | import { expandHomePath } from "../pathExpansion.ts"; | 16 | import { expandHomePath } from "../pathExpansion.ts"; |
| 17 | import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts"; | ||
| 17 | import { TextGenerationError } from "@t3tools/contracts"; | 18 | import { TextGenerationError } from "@t3tools/contracts"; |
| 18 | import * as TextGeneration from "./TextGeneration.ts"; | 19 | import * as TextGeneration from "./TextGeneration.ts"; |
| 19 | import { | 20 | import { |
| ... | @@ -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", ""); |
| 175 | 176 | ||
| 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 | }); |
| 23 | 24 |
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 | }); |
| 184 | 187 | ||
| 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 | ); |
| 203 | export type CodexSettings = typeof CodexSettings.Type; | 210 | export 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 | }); |
| 474 | 482 |
packages/shared/src/cliArgs.test.ts+29-1| ... | @@ -1,6 +1,27 @@ | ... | @@ -1,6 +1,27 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { parseCliArgs } from "./cliArgs.ts"; | 3 | import { parseCliArgs, tokenizeCliArgs } from "./cliArgs.ts"; |
| 4 | |||
| 5 | describe("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 | }); | ||
| 4 | 25 | ||
| 5 | describe("parseCliArgs", () => { | 26 | describe("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 | }); |
| 59 | 80 | ||
| 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 | } |
| 9 | 9 | ||
| 10 | export 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; |
| 38 | 94 | ||
| 39 | const flags: Record<string, string | null> = {}; | 95 | const flags: Record<string, string | null> = {}; |