Skip to content

Remote-created Codex Desktop threads on connected host do not receive automation_update/load_workspace_dependencies #24280

Open

Description

@jm-fhc

1. Feedback ID

019e4c10-cadb-7e70-8bf8-38245507bc2f

2. Product

Codex Desktop App / Remote Control / app-server thread/start dynamic tool provisioning.

3. Environment

  • Host: always-on Mac Mini running Codex Desktop
  • Client: MacBook Pro using the Codex remote workflow against the Mac Mini host
  • Host project path: ~/Documents/Codex/projects/business-intake-task-system
  • Codex Desktop app/server version observed in logs: 26.519.41501
  • CLI/session version: codex-cli 0.133.0-alpha.1
  • Platform: macOS / Darwin / Apple Silicon

Hostnames, user account paths, raw logs, databases, auth files, and unrelated prompt content are intentionally not included here.

4. Summary

Brand-new Codex Desktop threads created locally on the Mac Mini host receive the expected built-in Codex app dynamic tools:

  • codex_app.automation_update
  • codex_app.read_thread_terminal
  • codex_app.load_workspace_dependencies

Brand-new threads created from the MacBook Pro remote workflow against the same Mac Mini host and same project receive only:

  • codex_app.read_thread_terminal

If a thread is created locally on the Mac Mini first and then continued from the MacBook Pro, the full tool set remains available. So remote continuation works, host-local thread creation works, but brand-new remote thread creation appears to persist a reduced dynamic-tool set at thread creation time.

5. Steps to reproduce

  1. On a Mac Mini host running Codex Desktop, open a new project thread locally for a project path such as ~/Documents/Codex/projects/business-intake-task-system.
  2. Confirm the thread has codex_app.automation_update, codex_app.read_thread_terminal, and codex_app.load_workspace_dependencies.
  3. From a MacBook Pro, use the Codex remote workflow to create a brand-new thread on the same Mac Mini host and same project path.
  4. Inspect the new remote-created thread's dynamic tools.
  5. Separately, create a thread locally on the Mac Mini first, then continue that same thread from the MacBook Pro.

6. Expected behavior

A brand-new remote-created thread on a connected host should receive the same built-in Codex app dynamic tools as an equivalent host-local thread for the same project.

If remote-created threads are intentionally not automation-capable, Codex should clearly label them as such and provide an action to create or open an automation-capable host-local thread.

7. Actual behavior

Remote-created brand-new threads look like normal user project threads but are persisted with only codex_app.read_thread_terminal. They do not receive codex_app.automation_update or codex_app.load_workspace_dependencies, so they cannot create or update Codex automations.

8. Confirmed PASS cases

Mac Mini direct via deep link

  • Session: 019e5690-baaa-7161-8e15-5b3cbe023ffa
  • Dynamic tools:
    • codex_app.automation_update
    • codex_app.read_thread_terminal
    • codex_app.load_workspace_dependencies
  • App-server indicator: connection_id=0
  • Heartbeat/thread permission state: present in .codex-global-state.json

Mac Mini direct via normal Codex App UI new thread

  • Session: 019e5694-641d-79a3-9447-54f86074e996
  • Dynamic tools:
    • codex_app.automation_update
    • codex_app.read_thread_terminal
    • codex_app.load_workspace_dependencies
  • App-server indicator: connection_id=0
  • Heartbeat/thread permission state: present

9. Confirmed FAIL cases

MacBook Pro remote workflow, brand-new thread on the Mac Mini host

  • Session: 019e5697-260c-7d73-8941-4db90e80b7e6
  • Dynamic tools:
    • codex_app.read_thread_terminal
  • Missing:
    • codex_app.automation_update
    • codex_app.load_workspace_dependencies
  • App-server indicator: connection_id=12
  • Heartbeat/thread permission state: absent

Second MacBook Pro remote-created failing-pattern thread

  • Session: 019e56fe-b398-7c32-8768-2f3e656d0828
  • Dynamic tools:
    • codex_app.read_thread_terminal
  • Missing:
    • codex_app.automation_update
    • codex_app.load_workspace_dependencies
  • App-server indicator: connection_id=23
  • Heartbeat/thread permission state: absent

10. Remote continuation result

A Mac Mini-created thread was continued from the MacBook Pro:

  • Session: 019e5690-baaa-7161-8e15-5b3cbe023ffa
  • Dynamic tools remained:
    • codex_app.automation_update
    • codex_app.read_thread_terminal
    • codex_app.load_workspace_dependencies

This shows the MacBook Pro remote client can continue and use an automation-capable thread. The failure is specific to brand-new remote thread creation.

11. Evidence

  • state_5.sqlite thread rows are materially identical across pass/fail for cwd, source, thread_source, model, sandbox, approval mode, CLI version, and memory mode.
  • state_5.sqlite thread_dynamic_tools differs:
    • host-local created threads: 3 tools
    • remote-created threads: 1 tool
  • Session JSONL session_meta.dynamic_tools differs on the first session_meta line, so the mismatch exists at or before session creation.
  • logs_2.sqlite shows both paths using rpc.method=thread/start and rpc.transport=stdio with client_name=Codex Desktop and client_version=26.519.41501.
  • The visible differentiator is app-server connection identity:
    • host-local creation: connection_id=0
    • remote new-thread creation: nonzero remote connection IDs such as connection_id=12 and connection_id=23
  • .codex-global-state.json heartbeat-thread-permissions-by-id has entries for host-created passing threads but not for remote-created failing threads.
  • No log evidence showed automation_update being denied or filtered by entitlement, policy, sandbox, or approval settings.

12. Important negative findings

  • source=vscode is not causal. Passing and failing threads both report source=vscode.
  • GitHub Copilot Chat appears unrelated.
  • No active Codex/OpenAI/ChatGPT VS Code extension or bridge was found during inspection.
  • VS Code was not running during inspection.
  • Remote continuation does not strip automation_update.

13. Suspected root cause

The remote thread/start path appears to skip or use a reduced built-in dynamic-tool provisioning branch. It attaches read_thread_terminal but omits automation_update and load_workspace_dependencies, and it does not seed the same heartbeat/thread permission state that host-local thread creation seeds.

14. Workaround

Create the thread locally on the Mac Mini first, using either the Codex App UI or a deep link like:

open 'codex://threads/new?path=/Users/<user>/Documents/Codex/projects/business-intake-task-system'

Then continue that same thread from the MacBook Pro. Remote continuation preserves automation_update.

15. Scrubbed local evidence available

I can provide scrubbed excerpts from the relevant thread_dynamic_tools, session_meta.dynamic_tools, and app-server log rows if maintainers want them. I am not attaching raw logs, full JSONL files, auth files, database files, or unrelated prompt content because they may contain sensitive local context.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    app-serverIssues involving app server protocol or interfaces
    on May 23, 2026
  2. github-actions commented on May 23, 2026

    @github-actions
    Contributor

    Potential duplicates detected. Please review them and close your issue if it is a duplicate.

    Powered by Codex Action

  3. ax-openai commented on May 28, 2026

    @ax-openai

    Thanks for all the details. I'm looking into remote automations. I'll keep you posted here!

  4. self-assigned this
    on May 29, 2026
  5. jmabry commented on Jun 4, 2026

    @jmabry

    Confirmed I'm seeing the same bug

  6. davidwiet commented on Aug 3, 2026

    @davidwiet

    Still reproducible on 2026-08-03 with Codex Desktop release 26.727.40816 / embedded CLI 0.146.0-alpha.9.2.

    New affected Remote-created thread: 019fc5db-9e4d-7892-b5b1-e697cd7792c5. Its initial session_meta has neither thread_source nor dynamic_tools. Comparable locally initiated root threads on the same build have thread_source: "user" and the codex_app bundle containing automation_update; an automation-started thread has thread_source: "automation" and the bundle as well.

    At the affected thread creation time (2026-08-03T04:22:12Z), Desktop logs begin with browser/session route capture and contain no local thread/start -> Conversation created -> plugin/list sequence. A same-build locally initiated thread shows that complete sequence. Continuing the affected thread locally and switching models does not re-provision the missing tool bundle.

    Fresh diagnostics were uploaded through /feedback: 019fc658-2f6e-77e0-8cec-5a4382b2b2b5. The feedback description identifies the affected thread above and the prior working automation thread 019f66c5-c39c-7582-81fb-7fba477f3dbe.

  7. TheLoroXD commented on Aug 12, 2026

    @TheLoroXD

    Independent Linux + iOS Remote reproduction and no-UI workaround

    I reproduced this on 2026-08-12 with a Linux Desktop host:

    • ChatGPT/Codex Desktop RPM: 26.803.81509-1
    • Bundled Codex: 0.147.0-alpha.6.6
    • Host OS: Fedora Linux 43 container image, x86_64
    • Initiating client: ChatGPT iOS Remote
    • Execution target: a saved local Git project on the same Linux Desktop host

    Reproduction

    1. From iOS Remote, create a new Codex thread targeting a project on the connected Linux Desktop host.
    2. Continue the same thread later from Codex Desktop on that host.
    3. Ask the thread to create a same-thread heartbeat, or inspect its initial session_meta.dynamic_tools.

    The remote-created thread had normal local shell/project access and these host thread-management tools:

    create_thread
    fork_thread
    list_projects
    list_threads
    navigate_to_codex_page
    read_thread
    send_message_to_thread
    set_thread_archived
    set_thread_pinned
    set_thread_title
    

    However, automation_update was absent. Opening/resuming the thread directly in Desktop did not add it. The Desktop Scheduled UI could still create schedules, and a fresh local control thread on the same app/account/host did receive automation_update.

    This rules out Linux support, app version, account entitlement, and the host automation subsystem. The capability difference is tied to the thread creation route and remains sticky after resume.

    Additional project-routing inconsistency

    The affected remote-created thread could call both list_projects and create_thread, but:

    1. list_projects returned the saved Linux project as schema v2 with projectKind: local, hostId: local, and a UUID projectId.
    2. Passing that exact ID immediately to create_thread with environment: {type: "local"} failed with:
    Unknown projectId: <same UUID>. Call list_projects to find available projects.
    

    This overlaps #37630, although that report uses Realtime Voice/macOS while this reproduction is iOS Remote/Linux.

    Confirmed workaround without manually editing automation state

    A two-step local bootstrap worked:

    1. From the affected remote-created thread, call create_thread with a projectless target and carry the original request plus essential context into its prompt.
    2. That new thread is created with hostId: local; its initial session_meta.dynamic_tools includes automation_update.
    3. From the local bootstrap thread, call list_projects and then create_thread for the desired saved project with environment: {type: "local"}.
    4. The resulting project thread has the correct project cwd, remains hostId: local, and also includes automation_update.
    5. Continue the task from that project thread. It can create the heartbeat normally.

    Starting the project thread manually in Desktop and then continuing it from iOS also works, as already described in this issue.

    As a temporary guardrail, I added a global ~/.codex/AGENTS.md instruction that checks for automation_update on the first turn. If it is absent, the agent warns that the thread was probably initiated remotely and offers either to continue without agent-managed automations or create a fresh local thread carrying the request/context. This does not fix capability provisioning; it only prevents users from discovering the limitation halfway through a long-running task.

    Source-level observation

    Current main models dynamic tools as creation-time input:

    • ThreadStartParams.dynamic_tools:
      #[experimental("thread/start.dynamicTools")]
      #[serde(
      default,
      deserialize_with = "codex_protocol::dynamic_tools::deserialize_dynamic_tool_specs"
      )]
      #[ts(optional = nullable)]
      pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
    • ThreadResumeParams has no corresponding dynamic-tools refresh input:
      pub struct ThreadResumeParams {
      pub thread_id: String,
      /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE.
      /// If specified, the thread will be resumed with the provided history
      /// instead of loaded from disk.
      #[experimental("thread/resume.history")]
      #[ts(optional = nullable)]
      pub history: Option<Vec<ResponseItem>>,
      /// [UNSTABLE] Specify the rollout path to resume from.
      /// If specified for a non-running thread, the thread_id param will be ignored.
      /// If thread_id identifies a running thread, the path must match the active
      /// rollout path.
      #[experimental("thread/resume.path")]
      #[serde(
      default,
      deserialize_with = "crate::protocol::serde_helpers::deserialize_empty_path_as_none"
      )]
      #[ts(optional = nullable)]
      pub path: Option<PathBuf>,
      /// Configuration overrides for the resumed thread, if any.
      #[ts(optional = nullable)]
      pub model: Option<String>,
      #[ts(optional = nullable)]
      pub model_provider: Option<String>,
      #[serde(
      default,
      deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option",
      serialize_with = "crate::protocol::serde_helpers::serialize_double_option",
      skip_serializing_if = "Option::is_none"
      )]
      #[ts(optional = nullable)]
      pub service_tier: Option<Option<String>>,
      #[ts(optional = nullable)]
      pub cwd: Option<String>,
      /// Replace the thread's runtime workspace roots. Paths must be absolute.
      #[experimental("thread/resume.runtimeWorkspaceRoots")]
      #[ts(optional = nullable)]
      pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
      #[experimental(nested)]
      #[ts(optional = nullable)]
      pub approval_policy: Option<AskForApproval>,
      /// Override where approval requests are routed for review on this thread
      /// and subsequent turns.
      #[ts(optional = nullable)]
      pub approvals_reviewer: Option<ApprovalsReviewer>,
      #[ts(optional = nullable)]
      pub sandbox: Option<SandboxMode>,
      /// Named profile id for the resumed thread. Cannot be combined with
      /// `sandbox`.
      #[experimental("thread/resume.permissions")]
      #[ts(optional = nullable)]
      pub permissions: Option<String>,
      #[ts(optional = nullable)]
      pub config: Option<HashMap<String, serde_json::Value>>,
      #[ts(optional = nullable)]
      pub base_instructions: Option<String>,
      #[ts(optional = nullable)]
      pub developer_instructions: Option<String>,
      #[ts(optional = nullable)]
      pub personality: Option<Personality>,
      /// When true, return only thread metadata and live-resume state without
    • Core regression coverage explicitly verifies that resume restores dynamic tools from the rollout:
      #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
      async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Result<()> {
      let server = start_mock_server().await;
      let mock = mount_sse_sequence(
      &server,
      vec![
      responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
      responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
      ],
      )
      .await;
      let namespace = "resume_tools";
      let namespace_description = "Tools available after resume.";
      let tool_name = "resume_lookup";
      let tool_description = "Look up a value after resume.";
      let input_schema = json!({
      "type": "object",
      "properties": { "query": { "type": "string" } },
      "required": ["query"],
      "additionalProperties": false,
      });
      let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
      name: namespace.to_string(),
      description: namespace_description.to_string(),
      tools: vec![DynamicToolNamespaceTool::Function(
      DynamicToolFunctionSpec {
      name: tool_name.to_string(),
      description: tool_description.to_string(),
      input_schema: input_schema.clone(),
      defer_loading: false,
      },
      )],
      });
      let mut builder = test_codex().with_config(|config| {
      config
      .features
      .enable(Feature::Sqlite)
      .expect("test config should allow feature update");
      });
      let base_test = builder.build(&server).await?;
      let started = base_test
      .thread_manager
      .start_thread(StartThreadOptions {
      dynamic_tools: vec![dynamic_tool],
      ..StartThreadOptions::new(base_test.config.clone())
      })
      .await?;
      let rollout_path = started
      .session_configured
      .rollout_path
      .clone()
      .expect("rollout path");
      started
      .thread
      .submit(Op::UserInput {
      items: vec![UserInput::Text {
      text: "persist this thread".to_string(),
      text_elements: Vec::new(),
      }],
      final_output_json_schema: None,
      responsesapi_client_metadata: None,
      additional_context: Default::default(),
      thread_settings: Default::default(),
      })
      .await?;
      wait_for_event(&started.thread, |event| {
      matches!(event, EventMsg::TurnComplete(_))
      })
      .await;
      started.thread.shutdown_and_wait().await?;
      let mut resume_builder = test_codex().with_config(|config| {
      config
      .features
      .enable(Feature::Sqlite)
      .expect("test config should allow feature update");
      });

    That matches the observed behavior: a reduced tool set selected during remote thread/start is persisted, and later Desktop resume cannot repair it.

    Expected behavior

    A thread created remotely on a connected local host should receive the safe host-owned tools available to an equivalent locally-created thread. If that is intentionally restricted, the app should surface the limitation before starting work and offer a first-class Continue in a new local thread with current tools action that preserves the request and essential context.

    Related: #31613 for the iOS-specific symptom and #37630 for the list_projects/create_thread ID mismatch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

appIssues related to the Codex desktop appapp-serverIssues involving app server protocol or interfacesautomationsbugSomething isn't workingremote

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions