Skip to content

Commit 269e546

Browse files
committed
Resolve version-manager shims to stable paths for Codex CLI launches
When the resolved path for the Codex CLI is a version-manager shim (e.g., asdf or mise), this change resolves it to the underlying installed executable. This ensures that child subprocess launches remain stable and do not depend on the specific working directory where the shim was originally invoked. Key changes include: - Refactored CLI resolution to return a CommandSpec instead of just a path. - Updated internal launchers to utilize the resolved CommandSpec. - Added a connection test verifying that shim-based paths correctly resolve to stable locations. - Improved cleanup logic in Codex.Exec to avoid unnecessary waits after final stream events.
1 parent dd49be9 commit 269e546

21 files changed

Lines changed: 599 additions & 294 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ resolution follows this order:
7575
3. `System.find_executable("codex")`
7676

7777
Make sure the binary at the resolved location is executable and kept up to date.
78+
When the resolved path is a version-manager shim (for example `asdf`/`mise`), the SDK
79+
stabilizes it to the underlying installed executable when possible so child subprocess
80+
launches do not depend on the child working directory.
7881

7982
For authentication, sign in with your ChatGPT account (this stores credentials for the CLI):
8083

guides/05-app-server-transport.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ instead of relying on a pre-existing CLI login:
3030
initialization and optionally auto-answers refresh requests
3131

3232
The SDK resolves the `codex` executable via `codex_path_override``CODEX_PATH``System.find_executable("codex")`.
33+
When that path is a version-manager shim, the SDK resolves it to a stable installed
34+
binary when possible before launching the child process.
3335

3436
If you need the literal command surface instead of the managed JSON-RPC connection,
3537
`Codex.CLI.app_server/1` launches a raw `codex app-server` subprocess session and

lib/codex/app_server/connection.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,10 @@ defmodule Codex.AppServer.Connection do
121121
init_params =
122122
initialize_params(client_name, client_version, client_title, experimental_api)
123123

124-
with {:ok, binary_path} <- build_command(codex_opts),
124+
with {:ok, command_spec} <- build_command(codex_opts),
125125
{:ok, cwd} <- normalize_cwd(Keyword.get(opts, :cwd)),
126126
{:ok, env} <- build_env(codex_opts, opts),
127-
invocation <- Command.new(binary_path, app_server_args(codex_opts), cwd: cwd, env: env),
127+
invocation <- Command.new(command_spec, app_server_args(codex_opts), cwd: cwd, env: env),
128128
{:ok, raw_session} <-
129129
RawSession.start_link(
130130
invocation,
@@ -556,7 +556,7 @@ defmodule Codex.AppServer.Connection do
556556
end
557557
end
558558

559-
defp build_command(%Options{} = opts), do: Options.codex_path(opts)
559+
defp build_command(%Options{} = opts), do: Options.codex_command_spec(opts)
560560

561561
defp resolve_transport(opts) do
562562
opts

lib/codex/cli.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ defmodule Codex.CLI do
3333
cwd = resolve_cwd(opts)
3434

3535
with {:ok, codex_opts} <- normalize_codex_opts(opts),
36-
{:ok, binary_path} <- Options.codex_path(codex_opts),
36+
{:ok, command_spec} <- Options.codex_command_spec(codex_opts),
3737
{:ok, env_spec} <- build_env_spec(codex_opts, opts),
3838
{:ok, result} <-
3939
Command.run(
40-
Command.new(binary_path, normalize_args(args),
40+
Command.new(command_spec, normalize_args(args),
4141
cwd: cwd,
4242
env: env_spec.env,
4343
clear_env?: env_spec.clear_env?
@@ -58,7 +58,7 @@ defmodule Codex.CLI do
5858
cwd = resolve_cwd(opts)
5959

6060
with {:ok, codex_opts} <- normalize_codex_opts(opts),
61-
{:ok, binary_path} <- Options.codex_path(codex_opts),
61+
{:ok, command_spec} <- Options.codex_command_spec(codex_opts),
6262
{:ok, env_spec} <- build_env_spec(codex_opts, opts) do
6363
session_opts = [
6464
receiver: Keyword.get(opts, :receiver, self()),
@@ -68,7 +68,7 @@ defmodule Codex.CLI do
6868
env: build_session_env(env_spec)
6969
]
7070

71-
Session.start(binary_path, normalize_args(args), session_opts)
71+
Session.start(command_spec, normalize_args(args), session_opts)
7272
end
7373
end
7474

lib/codex/cli/session.ex

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ defmodule Codex.CLI.Session do
2222
Use `collect/2` to accumulate output until the process exits.
2323
"""
2424

25-
alias CliSubprocessCore.{Command, RawSession}
25+
alias CliSubprocessCore.{Command, CommandSpec, RawSession}
2626
alias CliSubprocessCore.Transport.Info
2727
alias Codex.Config.Defaults
2828
alias Codex.ProcessExit
@@ -53,11 +53,13 @@ defmodule Codex.CLI.Session do
5353
}
5454

5555
@doc """
56-
Starts a raw subprocess session for `binary_path` and `args`.
56+
Starts a raw subprocess session for a resolved Codex program and `args`.
5757
"""
58-
@spec start(String.t(), [String.t()], keyword()) :: {:ok, t()} | {:error, term()}
59-
def start(binary_path, args, opts \\ [])
60-
when is_binary(binary_path) and is_list(args) and is_list(opts) do
58+
@spec start(String.t() | CommandSpec.t(), [String.t()], keyword()) ::
59+
{:ok, t()} | {:error, term()}
60+
def start(binary_or_spec, args, opts \\ [])
61+
when (is_binary(binary_or_spec) or is_struct(binary_or_spec, CommandSpec)) and is_list(args) and
62+
is_list(opts) do
6163
receiver = Keyword.get(opts, :receiver, self())
6264
pty? = Keyword.get(opts, :pty, false)
6365
stdin? = Keyword.get(opts, :stdin, false)
@@ -66,7 +68,7 @@ defmodule Codex.CLI.Session do
6668

6769
with {:ok, {env, clear_env?}} <- normalize_env_spec(Keyword.get(opts, :env)),
6870
invocation <-
69-
Command.new(binary_path, args,
71+
Command.new(binary_or_spec, args,
7072
cwd: Keyword.get(opts, :cwd),
7173
env: env,
7274
clear_env?: clear_env?
@@ -87,7 +89,7 @@ defmodule Codex.CLI.Session do
8789
{:ok,
8890
%__MODULE__{
8991
args: args,
90-
command: [binary_path | args],
92+
command: Command.argv(invocation),
9193
os_pid: os_pid,
9294
pid: pid,
9395
raw_session: raw_session,

lib/codex/exec.ex

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ defmodule Codex.Exec do
258258
defp safe_stop(%{session: session, session_ref: session_ref} = state) when is_pid(session) do
259259
maybe_unregister_cancellation(state)
260260
_ = RuntimeExec.close(session)
261-
await_session_down_or_demonitor(state.session_monitor_ref, session)
261+
maybe_await_session_down(state.session_monitor_ref, session)
262262
flush_session_messages(session_ref, state.session_event_tag)
263263
:ok
264264
rescue
@@ -318,6 +318,18 @@ defmodule Codex.Exec do
318318

319319
defp await_session_down_or_demonitor(_ref, _session), do: :ok
320320

321+
defp maybe_await_session_down(ref, session)
322+
when is_reference(ref) and is_pid(session) do
323+
if Process.alive?(session) do
324+
await_session_down_or_demonitor(ref, session)
325+
else
326+
Process.demonitor(ref, [:flush])
327+
:ok
328+
end
329+
end
330+
331+
defp maybe_await_session_down(_ref, _session), do: :ok
332+
321333
defp flush_session_messages(ref, session_event_tag)
322334
when is_reference(ref) and is_atom(session_event_tag) do
323335
receive do

lib/codex/options.ex

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule Codex.Options do
66
"""
77

88
require Bitwise
9-
alias CliSubprocessCore.ModelInput
9+
alias CliSubprocessCore.{CommandSpec, ModelInput, ProviderCLI}
1010
alias Codex.Auth
1111
alias Codex.Config.BaseURL
1212
alias Codex.Config.OptionNormalizers
@@ -127,37 +127,49 @@ defmodule Codex.Options do
127127
end
128128

129129
@doc """
130-
Determines the executable path to `codex-rs`.
130+
Determines a stable command spec for launching `codex`.
131131
132132
Order of precedence:
133133
1. Explicit override on the struct.
134134
2. `CODEX_PATH` environment variable.
135135
3. `System.find_executable("codex")`.
136136
"""
137-
@spec codex_path(t()) :: {:ok, String.t()} | {:error, term()}
138-
def codex_path(%__MODULE__{codex_path_override: override}) when is_binary(override) do
139-
validate_executable(override)
137+
@spec codex_command_spec(t()) :: {:ok, CommandSpec.t()} | {:error, term()}
138+
def codex_command_spec(%__MODULE__{codex_path_override: override}) when is_binary(override) do
139+
with {:ok, path} <- validate_executable(override) do
140+
ProviderCLI.resolve(:codex, command: path)
141+
end
140142
end
141143

142-
def codex_path(%__MODULE__{} = opts) do
144+
def codex_command_spec(%__MODULE__{} = _opts) do
143145
env_path = System.get_env("CODEX_PATH")
144146

145-
path =
146-
if env_path && env_path != "" do
147-
env_path
148-
else
149-
System.find_executable("codex")
147+
if is_binary(env_path) and env_path != "" do
148+
with {:ok, path} <- validate_executable(env_path) do
149+
ProviderCLI.resolve(:codex, command: path)
150+
end
151+
else
152+
case ProviderCLI.resolve(:codex) do
153+
{:ok, spec} -> {:ok, spec}
154+
{:error, %ProviderCLI.Error{kind: :cli_not_found}} -> {:error, :codex_binary_not_found}
155+
{:error, reason} -> {:error, reason}
150156
end
151-
152-
case path do
153-
nil -> {:error, :codex_binary_not_found}
154-
path -> validate_executable(path)
155157
end
156-
|> add_override_ref(opts)
157158
end
158159

159-
defp add_override_ref(result, %__MODULE__{codex_path_override: nil}), do: result
160-
defp add_override_ref(result, _opts), do: result
160+
@doc """
161+
Determines the stable executable path to `codex`.
162+
163+
This returns the resolved program from `codex_command_spec/1`. Internal
164+
launchers should prefer `codex_command_spec/1` so argv prefixes remain
165+
available when needed.
166+
"""
167+
@spec codex_path(t()) :: {:ok, String.t()} | {:error, term()}
168+
def codex_path(%__MODULE__{} = opts) do
169+
with {:ok, %CommandSpec{program: program}} <- codex_command_spec(opts) do
170+
{:ok, program}
171+
end
172+
end
161173

162174
defp fetch_api_key(attrs) do
163175
case normalize_string(pick(attrs, [:api_key, "api_key"], Auth.api_key())) do

lib/codex/plugins.ex

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,34 @@ defmodule Codex.Plugins do
1111

1212
alias Codex.Plugins.{Manifest, Marketplace, Reader, Scaffold, Writer}
1313

14+
@type scaffold_result :: %{
15+
scope: Codex.Plugins.Paths.scope(),
16+
plugin_name: String.t(),
17+
plugin_root: String.t(),
18+
manifest_path: String.t(),
19+
manifest: Manifest.t(),
20+
skill_paths: [String.t()],
21+
marketplace_path: String.t() | nil,
22+
marketplace: Marketplace.t() | nil,
23+
created_paths: [String.t()]
24+
}
25+
26+
@type scaffold_error ::
27+
{:plugin_io, %{action: atom(), path: String.t(), reason: term()}}
28+
| {:invalid_plugin_json, %{path: String.t(), reason: String.t(), raw_reason: term()}}
29+
| {:plugin_file_exists, %{path: String.t()}}
30+
| {:repo_root_not_found, %{cwd: String.t()}}
31+
| {:invalid_plugin_scope, %{scope: term()}}
32+
| {:invalid_plugin_name, %{name: term(), message: String.t()}}
33+
| {:invalid_plugin_root, %{path: String.t(), message: String.t()}}
34+
| {:invalid_marketplace_path, %{path: String.t(), message: String.t()}}
35+
| {:invalid_marketplace_source_path,
36+
%{path: String.t(), source_path: String.t(), message: String.t()}}
37+
| {:plugin_conflict, %{path: String.t(), plugin_name: String.t()}}
38+
| {:invalid_plugin_manifest, CliSubprocessCore.Schema.error_detail()}
39+
| {:invalid_plugin_marketplace, CliSubprocessCore.Schema.error_detail()}
40+
| {:invalid_plugin_marketplace_plugin, CliSubprocessCore.Schema.error_detail()}
41+
1442
@doc """
1543
Builds and validates a manifest struct.
1644
"""
@@ -93,7 +121,7 @@ defmodule Codex.Plugins do
93121
@doc """
94122
Scaffolds a minimal local plugin tree.
95123
"""
96-
@spec scaffold(keyword()) :: {:ok, map()} | {:error, term()}
124+
@spec scaffold(keyword()) :: {:ok, scaffold_result()} | {:error, scaffold_error()}
97125
def scaffold(opts), do: Scaffold.scaffold(opts)
98126

99127
defp to_validation_result({:ok, _struct}), do: :ok

lib/codex/plugins/errors.ex

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,80 @@
11
defmodule Codex.Plugins.Errors do
22
@moduledoc false
33

4-
@spec io(atom(), Path.t(), term()) :: {:plugin_io, map()}
5-
def io(action, path, reason) when is_atom(action) do
4+
@type io_error :: {:plugin_io, %{action: atom(), path: String.t(), reason: term()}}
5+
@type invalid_json_error ::
6+
{:invalid_plugin_json, %{path: String.t(), reason: String.t(), raw_reason: term()}}
7+
@type file_exists_error :: {:plugin_file_exists, %{path: String.t()}}
8+
@type repo_root_not_found_error :: {:repo_root_not_found, %{cwd: String.t()}}
9+
@type invalid_scope_error :: {:invalid_plugin_scope, %{scope: term()}}
10+
@type invalid_plugin_name_error ::
11+
{:invalid_plugin_name, %{name: term(), message: String.t()}}
12+
@type invalid_plugin_root_error ::
13+
{:invalid_plugin_root, %{path: String.t(), message: String.t()}}
14+
@type invalid_marketplace_path_error ::
15+
{:invalid_marketplace_path, %{path: String.t(), message: String.t()}}
16+
@type invalid_marketplace_source_path_error ::
17+
{:invalid_marketplace_source_path,
18+
%{path: String.t(), source_path: String.t(), message: String.t()}}
19+
@type plugin_conflict_error ::
20+
{:plugin_conflict, %{path: String.t(), plugin_name: String.t()}}
21+
22+
@type t ::
23+
io_error()
24+
| invalid_json_error()
25+
| file_exists_error()
26+
| repo_root_not_found_error()
27+
| invalid_scope_error()
28+
| invalid_plugin_name_error()
29+
| invalid_plugin_root_error()
30+
| invalid_marketplace_path_error()
31+
| invalid_marketplace_source_path_error()
32+
| plugin_conflict_error()
33+
34+
@spec io(atom(), String.t(), term()) :: io_error()
35+
def io(action, path, reason) when is_atom(action) and is_binary(path) do
636
{:plugin_io, %{action: action, path: Path.expand(path), reason: reason}}
737
end
838

9-
@spec invalid_json(Path.t(), term()) :: {:invalid_plugin_json, map()}
10-
def invalid_json(path, reason) do
39+
@spec invalid_json(String.t(), term()) :: invalid_json_error()
40+
def invalid_json(path, reason) when is_binary(path) do
1141
{:invalid_plugin_json,
1242
%{path: Path.expand(path), reason: format_reason(reason), raw_reason: reason}}
1343
end
1444

15-
@spec file_exists(Path.t()) :: {:plugin_file_exists, map()}
16-
def file_exists(path), do: {:plugin_file_exists, %{path: Path.expand(path)}}
45+
@spec file_exists(String.t()) :: file_exists_error()
46+
def file_exists(path) when is_binary(path),
47+
do: {:plugin_file_exists, %{path: Path.expand(path)}}
1748

18-
@spec repo_root_not_found(Path.t()) :: {:repo_root_not_found, map()}
19-
def repo_root_not_found(path), do: {:repo_root_not_found, %{cwd: Path.expand(path)}}
49+
@spec repo_root_not_found(String.t()) :: repo_root_not_found_error()
50+
def repo_root_not_found(path) when is_binary(path),
51+
do: {:repo_root_not_found, %{cwd: Path.expand(path)}}
2052

21-
@spec invalid_scope(term()) :: {:invalid_plugin_scope, map()}
53+
@spec invalid_scope(term()) :: invalid_scope_error()
2254
def invalid_scope(scope), do: {:invalid_plugin_scope, %{scope: scope}}
2355

24-
@spec invalid_plugin_name(term(), String.t()) :: {:invalid_plugin_name, map()}
56+
@spec invalid_plugin_name(term(), String.t()) :: invalid_plugin_name_error()
2557
def invalid_plugin_name(name, message),
2658
do: {:invalid_plugin_name, %{name: name, message: message}}
2759

28-
@spec invalid_plugin_root(Path.t(), String.t()) :: {:invalid_plugin_root, map()}
29-
def invalid_plugin_root(path, message),
60+
@spec invalid_plugin_root(String.t(), String.t()) :: invalid_plugin_root_error()
61+
def invalid_plugin_root(path, message) when is_binary(path),
3062
do: {:invalid_plugin_root, %{path: Path.expand(path), message: message}}
3163

32-
@spec invalid_marketplace_path(Path.t(), String.t()) :: {:invalid_marketplace_path, map()}
33-
def invalid_marketplace_path(path, message),
64+
@spec invalid_marketplace_path(String.t(), String.t()) :: invalid_marketplace_path_error()
65+
def invalid_marketplace_path(path, message) when is_binary(path),
3466
do: {:invalid_marketplace_path, %{path: Path.expand(path), message: message}}
3567

36-
@spec invalid_marketplace_source_path(Path.t(), String.t(), String.t()) ::
37-
{:invalid_marketplace_source_path, map()}
38-
def invalid_marketplace_source_path(path, source_path, message) do
68+
@spec invalid_marketplace_source_path(String.t(), String.t(), String.t()) ::
69+
invalid_marketplace_source_path_error()
70+
def invalid_marketplace_source_path(path, source_path, message)
71+
when is_binary(path) and is_binary(source_path) do
3972
{:invalid_marketplace_source_path,
4073
%{path: Path.expand(path), source_path: source_path, message: message}}
4174
end
4275

43-
@spec plugin_conflict(Path.t(), String.t()) :: {:plugin_conflict, map()}
44-
def plugin_conflict(path, plugin_name) do
76+
@spec plugin_conflict(String.t(), String.t()) :: plugin_conflict_error()
77+
def plugin_conflict(path, plugin_name) when is_binary(path) and is_binary(plugin_name) do
4578
{:plugin_conflict, %{path: Path.expand(path), plugin_name: plugin_name}}
4679
end
4780

0 commit comments

Comments
 (0)