Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ pub struct TurnStartParams {
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Optional client-provided context fragments keyed by an opaque source identifier.
// App-server request validation only accepts `untrusted` here. `application` is reserved for
// trusted server-owned context handled before client request submission.
#[experimental("turn/start.additionalContext")]
#[ts(optional = nullable)]
pub additional_context: Option<HashMap<String, AdditionalContextEntry>>,
Expand Down Expand Up @@ -192,6 +194,8 @@ pub struct TurnSteerParams {
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Optional client-provided context fragments keyed by an opaque source identifier.
// App-server request validation only accepts `untrusted` here. `application` is reserved for
// trusted server-owned context handled before client request submission.
#[experimental("turn/steer.additionalContext")]
#[ts(optional = nullable)]
pub additional_context: Option<HashMap<String, AdditionalContextEntry>>,
Expand Down
14 changes: 11 additions & 3 deletions codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,9 @@ Example with notification opt-out:
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codewith generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Pass `modelProvider` with `model` when switching to a provider whose model slugs are not provider-prefixed. Experimental `additionalContext` injects keyed context fragments before the turn input; each entry declares its trust `kind` and may include `source` provenance for local app-store records such as OpenProjects projects, OpenTodos tasks, Conversations threads, or Mementos notes. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `turn/start` — add user input to a thread and begin Codewith generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Pass `modelProvider` with `model` when switching to a provider whose model slugs are not provider-prefixed. Experimental `additionalContext` injects keyed context fragments before the turn input; normal app-server clients may only send `kind: "untrusted"` and may include `source` provenance for local app-store records such as OpenProjects projects, OpenTodos tasks, Conversations threads, or Mementos notes. `kind: "application"` is reserved for trusted server-owned context and is rejected by `turn/start`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Like `turn/start`, experimental `additionalContext` may refresh keyed context for the active turn. Review and manual compaction turns reject `turn/steer`.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `additionalContext` has the same trust and size limits as `turn/start` and may refresh keyed untrusted context for the active turn. Review and manual compaction turns reject `turn/steer`.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`.
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
Expand Down Expand Up @@ -1323,7 +1323,12 @@ You can optionally specify config overrides on the new turn. If specified, these

### Example: Start a turn with sourced context

Use experimental `additionalContext` when a host app wants Codewith to see local app-store context without adding a visible user message. The map key is the stable dedupe key for that fragment. `kind` controls trust: use `"untrusted"` for project/task/conversation/memento content unless it was generated by the trusted host application. Optional `source` metadata identifies the local record so clients can render, edit, refresh, or remove the context source without granting the agent hidden write access to that store.
Use experimental `additionalContext` when a host app wants Codewith to see local app-store context without adding a visible user message. The map key is the stable dedupe key for that fragment. Normal app-server clients may only send entries with `kind: "untrusted"`; these are rendered as external user context. `kind: "application"` is reserved for trusted server-owned context and is rejected on client `turn/start` and `turn/steer` requests. Optional `source` metadata identifies the local record so clients can render, edit, refresh, or remove the context source without granting the agent hidden write access to that store.

The request is rejected with `invalid params` before model submission if
`additionalContext` exceeds 16 entries, a key exceeds 128 characters, a value exceeds 65,536
bytes, or all keys, values, and source metadata exceed 131,072 bytes. Limit errors include
`input_error_code: "input_too_large"` in the JSON-RPC error data.

```json
{ "method": "turn/start", "id": 31, "params": {
Expand Down Expand Up @@ -1507,6 +1512,9 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi
Use `turn/steer` to append additional user input to the currently active regular turn. This does
not emit `turn/started` and does not accept thread settings overrides.

`additionalContext`, when provided, follows the same `untrusted`-only trust boundary and size
limits documented for `turn/start`.

```json
{ "method": "turn/steer", "id": 32, "params": {
"threadId": "thr_123",
Expand Down
175 changes: 156 additions & 19 deletions codex-rs/app-server/src/request_processors/turn_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ use super::*;
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;

const MAX_ADDITIONAL_CONTEXT_ENTRIES: usize = 16;
const MAX_ADDITIONAL_CONTEXT_KEY_CHARS: usize = 128;
const MAX_ADDITIONAL_CONTEXT_VALUE_BYTES: usize = 64 * 1024;
const MAX_ADDITIONAL_CONTEXT_TOTAL_BYTES: usize = 128 * 1024;

#[derive(Clone)]
pub(crate) struct TurnRequestProcessor {
auth_manager: Arc<AuthManager>,
Expand All @@ -20,32 +25,142 @@ pub(crate) struct TurnRequestProcessor {
local_active_owner_id: String,
}

fn map_additional_context(
fn additional_context_input_too_large_error(
message: String,
data: serde_json::Value,
) -> JSONRPCErrorError {
let mut error = invalid_params(message);
error.data = Some(data);
error
}

fn reject_application_additional_context_error() -> JSONRPCErrorError {
invalid_params(
"additionalContext kind `application` is reserved for trusted server-owned context and \
cannot be supplied by turn/start or turn/steer clients",
)
}

fn additional_context_error_type(error: &JSONRPCErrorError) -> Option<AnalyticsJsonRpcError> {
let is_too_large = error.data.as_ref().is_some_and(|data| {
data.get("input_error_code")
.and_then(serde_json::Value::as_str)
== Some(INPUT_TOO_LARGE_ERROR_CODE)
});
if is_too_large {
Some(AnalyticsJsonRpcError::Input(InputError::TooLarge))
} else {
None
}
}

fn validate_and_map_additional_context(
additional_context: Option<HashMap<String, AdditionalContextEntry>>,
) -> BTreeMap<String, CoreAdditionalContextEntry> {
additional_context
.unwrap_or_default()
) -> Result<BTreeMap<String, CoreAdditionalContextEntry>, JSONRPCErrorError> {
let Some(additional_context) = additional_context else {
return Ok(BTreeMap::new());
};

let entry_count = additional_context.len();
if entry_count > MAX_ADDITIONAL_CONTEXT_ENTRIES {
return Err(additional_context_input_too_large_error(
format!(
"additionalContext must not contain more than \
{MAX_ADDITIONAL_CONTEXT_ENTRIES} entries."
),
serde_json::json!({
"input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
"input_field": "additionalContext",
"limit_name": "max_entries",
"max_entries": MAX_ADDITIONAL_CONTEXT_ENTRIES,
"actual_entries": entry_count,
}),
));
}

if additional_context
.values()
.any(|entry| matches!(entry.kind, AdditionalContextKind::Application))
{
return Err(reject_application_additional_context_error());
}

let mut total_bytes = 0usize;
for (key, entry) in &additional_context {
let key_chars = key.chars().count();
if key_chars > MAX_ADDITIONAL_CONTEXT_KEY_CHARS {
return Err(additional_context_input_too_large_error(
format!(
"additionalContext keys must not exceed \
{MAX_ADDITIONAL_CONTEXT_KEY_CHARS} characters."
),
serde_json::json!({
"input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
"input_field": "additionalContext",
"limit_name": "max_key_chars",
"max_chars": MAX_ADDITIONAL_CONTEXT_KEY_CHARS,
"actual_chars": key_chars,
}),
));
}

let value_bytes = entry.value.len();
if value_bytes > MAX_ADDITIONAL_CONTEXT_VALUE_BYTES {
return Err(additional_context_input_too_large_error(
format!(
"additionalContext values must not exceed \
{MAX_ADDITIONAL_CONTEXT_VALUE_BYTES} bytes."
),
serde_json::json!({
"input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
"input_field": "additionalContext",
"limit_name": "max_value_bytes",
"max_bytes": MAX_ADDITIONAL_CONTEXT_VALUE_BYTES,
"actual_bytes": value_bytes,
}),
));
}

total_bytes = total_bytes
.saturating_add(key.len())
.saturating_add(value_bytes);
if let Some(source) = &entry.source {
total_bytes = total_bytes
.saturating_add(source.namespace.len())
.saturating_add(source.id.len())
.saturating_add(source.record_type.as_deref().map(str::len).unwrap_or(0))
.saturating_add(source.label.as_deref().map(str::len).unwrap_or(0));
}
}

if total_bytes > MAX_ADDITIONAL_CONTEXT_TOTAL_BYTES {
return Err(additional_context_input_too_large_error(
format!(
"additionalContext must not exceed \
{MAX_ADDITIONAL_CONTEXT_TOTAL_BYTES} aggregate bytes."
),
serde_json::json!({
"input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
"input_field": "additionalContext",
"limit_name": "max_total_bytes",
"max_bytes": MAX_ADDITIONAL_CONTEXT_TOTAL_BYTES,
"actual_bytes": total_bytes,
}),
));
}

Ok(additional_context
.into_iter()
.map(|(key, entry)| {
let AdditionalContextEntry {
value,
kind,
source: _,
} = entry;
(
key,
CoreAdditionalContextEntry {
value,
kind: match kind {
AdditionalContextKind::Untrusted => CoreAdditionalContextKind::Untrusted,
AdditionalContextKind::Application => {
CoreAdditionalContextKind::Application
}
},
value: entry.value,
kind: CoreAdditionalContextKind::Untrusted,
},
)
})
.collect()
.collect())
}

fn existing_dir_is_inside(path: &Path, root: &Path) -> std::io::Result<bool> {
Expand Down Expand Up @@ -430,7 +545,18 @@ impl TurnRequestProcessor {
.map(V2UserInput::into_core)
.collect();
let client_user_message_id = params.client_user_message_id;
let additional_context = map_additional_context(params.additional_context);
let additional_context =
match validate_and_map_additional_context(params.additional_context) {
Ok(additional_context) => additional_context,
Err(error) => {
self.track_error_response(
&request_id,
&error,
additional_context_error_type(&error),
);
return Err(error);
}
};
let turn_has_input = !mapped_items.is_empty();
let cwd = resolve_request_cwd(params.cwd)?;
let thread_settings = self
Expand Down Expand Up @@ -872,7 +998,18 @@ impl TurnRequestProcessor {
.into_iter()
.map(V2UserInput::into_core)
.collect();
let additional_context = map_additional_context(params.additional_context);
let additional_context =
match validate_and_map_additional_context(params.additional_context) {
Ok(additional_context) => additional_context,
Err(error) => {
self.track_error_response(
request_id,
&error,
additional_context_error_type(&error),
);
return Err(error);
}
};

let turn_id = thread
.steer_input(
Expand Down
Loading
Loading