Skip to content

feat(sandbox): expose services during creation - #3439

Open
drew wants to merge 7 commits into
mainfrom
codex/sandbox-service-exposure
Open

drew wants to merge 7 commits into
mainfrom
codex/sandbox-service-exposure

Conversation

@drew

@drew drew commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add create-time sandbox service exposure across the gateway API, CLI, and maintained SDKs, returning routed service URLs keyed by service name. Include a Codex app-server example that uses gateway-managed credential refresh and connects a local Codex client through the exposed WebSocket endpoint.

Related Issue

Closes #3402

Changes

  • Add create-time service exposure requests and service URL maps to the protobuf API
  • Support openshell sandbox create --expose PORT, structured URL output, validation, and rollback when endpoint persistence fails
  • Expose named and unnamed services from the Rust, Python, TypeScript, and Go SDK create APIs
  • Normalize create-time URLs against the externally configured gateway port
  • Add Codex OAuth refresh metadata so the gateway owns refresh material
  • Add the examples/codex-app-server image, launcher, and local-client workflow
  • Update sandbox documentation, architecture notes, and the public CLI skill

Testing

  • mise run pre-commit passes
  • Unit tests added/updated
  • E2E tests added/updated (not included; the example was exercised manually against a local gateway)

Additional focused tests:

  • cargo test -p openshell-server create_sandbox_begins_rollback_when_service_exposure_fails
  • cargo test -p openshell-server create_sandbox_registers_requested_service_exposures
  • cargo test -p openshell-cli --test sandbox_create_lifecycle_integration sandbox_create_json_stdout_is_parseable
  • cargo test -p openshell-cli service_url_for_gateway_uses_external_gateway_port

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable)

@mrunalp

mrunalp commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Potential Concerns

  1. Create replay would lose service_urls. Current main reconstructs replayed CreateSandbox responses using only the sandbox ID. After this PR, a retry with the same request_id could successfully replay creation but return an empty URL map. The replay receipt/restore logic needs to
    preserve or reconstruct the URLs, with a retry test. Replay logic (

    fn sandbox_receipt(sandbox: Option<&Sandbox>, changed: bool) -> Result<Outcome, Status> {
    Ok(Outcome::Sandbox {
    id: sandbox.ok_or_else(uncertain)?.object_id().into(),
    changed,
    })
    }
    macro_rules! sandbox_mutation {
    ($req:ty, $method:literal, $handler:path) => {
    scoped_mutation!(
    $req,
    SandboxResponse,
    $method,
    $handler,
    User,
    |response: &Response<SandboxResponse>| sandbox_receipt(
    response.get_ref().sandbox.as_ref(),
    false
    ),
    async |store: &Store, outcome: Outcome| {
    let Outcome::Sandbox { id, .. } = outcome else {
    return Err(replay_unavailable());
    };
    Ok(SandboxResponse {
    sandbox: Some(live(store, &id).await?),
    })
    }
    );
    };
    }
    sandbox_mutation!(
    CreateSandboxRequest,
    "CreateSandbox",
    sandbox::handle_create_sandbox
    );
    )

  2. Rollback can delete a same-name replacement. On exposure failure, the handler knows the created sandbox ID but calls deletion by name. If concurrent deletion and recreation occur before name resolution, rollback can target the replacement. Use an ID-fenced internal deletion
    path. Rollback call (

    let mut service_urls = HashMap::with_capacity(request.service_exposures.len());
    for exposure in &request.service_exposures {
    let endpoint = match super::service::expose_service_endpoint(
    state,
    sandbox.object_workspace(),
    &sandbox,
    &exposure.service,
    exposure.target_port,
    )
    .await
    {
    Ok(endpoint) => endpoint,
    Err(exposure_error) => {
    let rollback = state
    .compute
    .delete_sandbox(sandbox.object_workspace(), sandbox.object_name())
    .await;
    if let Err(rollback_error) = rollback {
    warn!(
    sandbox_id = %sandbox.object_id(),
    sandbox_name = %sandbox.object_name(),
    service_name = %exposure.service,
    exposure_error = %exposure_error,
    rollback_error = %rollback_error,
    "Failed to roll back sandbox after service exposure failed"
    );
    return Err(Status::internal(format!(
    "create sandbox failed while exposing service '{}': {}; rollback failed: {}; sandbox '{}' may require manual deletion",
    exposure.service,
    exposure_error.message(),
    rollback_error.message(),
    sandbox.object_name(),
    )));
    }
    return Err(exposure_error);
    }
    ), name resolution
    (
    pub(crate) async fn delete_sandbox(
    &self,
    workspace: &str,
    name: &str,
    ) -> Result<DeleteSandboxResult, Status> {
    self.delete_sandbox_allow_missing(workspace, name, false)
    .await
    }
    pub(crate) async fn delete_sandbox_allow_missing(
    &self,
    workspace: &str,
    name: &str,
    allow_missing: bool,
    ) -> Result<DeleteSandboxResult, Status> {
    // Resolve and acquire both request-side locks before spawning the
    // owned worker. Cancellation while any of these awaits is pending is
    // harmless because no mutation or detached work has started.
    let candidate = self
    .store
    .get_message_by_name::<Sandbox>(workspace, name)
    .await
    .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?;
    let Some(candidate) = candidate else {
    if allow_missing {
    return Ok(DeleteSandboxResult {
    sandbox_id: String::new(),
    outcome: openshell_core::proto::DeletionOutcome::AlreadyAbsent,
    });
    }
    return Err(Status::not_found("sandbox not found"));
    };
    let target = SandboxDeleteTarget {
    sandbox_id: candidate.object_id().to_string(),
    sandbox_name: candidate.object_name().to_string(),
    };
    )

  3. The Codex example eventually invokes the placeholder refresh token. It writes auth_mode: "chatgpt" and a fake refresh token. Codex cannot parse the opaque OpenShell access-token handle as a JWT, so after eight days it falls back to proactive refresh using that placeholder,
    causing recurring refresh failures and latency. Use Codex’s externally managed chatgptAuthTokens mode instead. Example launcher (

    id_token: idToken,
    access_token: process.env.CODEX_AUTH_ACCESS_TOKEN,
    // The gateway owns refresh material and rotates the access-token handle.
    refresh_token: "gateway-managed-refresh-token",
    account_id: process.env.CODEX_AUTH_ACCOUNT_ID,
    },
    last_refresh: new Date().toISOString(),
    }, null, 2), { mode: 0o600 });
    const port = process.env.CODEX_APP_SERVER_PORT || "4500";
    const server = spawn(
    "codex",
    ["app-server", "--listen", `ws://127.0.0.1:${port}`],
    ), Codex
    refresh logic (https://github.com/openai/codex/blob/rust-v0.155.0/codex-rs/login/src/auth/manager.rs#L2959-L2980), external auth mode (https://github.com/openai/codex/blob/rust-v0.155.0/codex-rs/protocol/src/auth.rs#L7-L17)

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
@drew
drew force-pushed the codex/sandbox-service-exposure branch from 97fddfc to 207dcb6 Compare September 19, 2026 02:38
@drew

drew commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed all three review points and rebased onto current main:

  • create mutation replay now preserves service_urls, with a retry/replay regression test
  • failed exposure rollback now deletes by the created sandbox ID, so a same-name replacement cannot be removed; added a replacement-race regression test
  • the Codex launcher now uses chatgptAuthTokens external auth mode with no synthetic refresh token, leaving refresh ownership with the gateway

Validation: pre-commit, full openshell-server suite, sandbox create lifecycle integration (40 passed, 2 ignored), TypeScript SDK CI, Python tests, and focused Go SDK tests.

@github-actions

Copy link
Copy Markdown

Comment thread providers/codex.yaml

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johntmyers do you mind taking a look at this change and making sure it looks ok?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: expose sandbox services during creation

2 participants