mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
sync: update OpenAI mirror and Just Every product changes
Mirror openai/codex c308ffb36 exactly under vendor/codex. Apply the code-rs delta from just-every/code 7d59424890 to af789f51a, retaining Hanzo defaults, authentication, branding, and vendor paths. Give the compatibility code binary its own entry file to eliminate duplicate-target warnings.
Validated: BUILD_FAST_BINS="dev code" ./build-fast.sh, both binary versions, shell completion, and interactive Hanzo TUI startup.
This commit is contained in:
@@ -1276,8 +1276,26 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpServerOauthClientRegistration": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"dcr"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpServerOauthLoginParams": {
|
||||
"properties": {
|
||||
"clientRegistration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/McpServerOauthClientRegistration"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Registration strategy for this login only; omission preserves automatic DCR."
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"enum": [
|
||||
"context_window_exceeded",
|
||||
"usage_limit_exceeded",
|
||||
"rate_limit_exceeded",
|
||||
"cyber_policy",
|
||||
"internal_server_error",
|
||||
"unauthorized",
|
||||
|
||||
@@ -406,6 +406,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
@@ -549,6 +550,7 @@
|
||||
"enum": [
|
||||
"context_window_exceeded",
|
||||
"usage_limit_exceeded",
|
||||
"rate_limit_exceeded",
|
||||
"cyber_policy",
|
||||
"internal_server_error",
|
||||
"unauthorized",
|
||||
|
||||
@@ -2076,6 +2076,7 @@
|
||||
"enum": [
|
||||
"context_window_exceeded",
|
||||
"usage_limit_exceeded",
|
||||
"rate_limit_exceeded",
|
||||
"cyber_policy",
|
||||
"internal_server_error",
|
||||
"unauthorized",
|
||||
@@ -11071,6 +11072,29 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"AutoReviewRequirements": {
|
||||
"properties": {
|
||||
"ignoreRules": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"requiredOnModels": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ByteRange": {
|
||||
"properties": {
|
||||
"end": {
|
||||
@@ -11130,6 +11154,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
@@ -12095,6 +12120,16 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"autoReview": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AutoReviewRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"enforceResidency": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -13391,6 +13426,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpServerOauthClientRegistration": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"dcr"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpServerOauthLoginCompletedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -13417,6 +13459,17 @@
|
||||
"McpServerOauthLoginParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"clientRegistration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/McpServerOauthClientRegistration"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Registration strategy for this login only; omission preserves automatic DCR."
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"enum": [
|
||||
"context_window_exceeded",
|
||||
"usage_limit_exceeded",
|
||||
"rate_limit_exceeded",
|
||||
"cyber_policy",
|
||||
"internal_server_error",
|
||||
"unauthorized",
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"enum": [
|
||||
"context_window_exceeded",
|
||||
"usage_limit_exceeded",
|
||||
"rate_limit_exceeded",
|
||||
"cyber_policy",
|
||||
"internal_server_error",
|
||||
"unauthorized",
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"enum": [
|
||||
"context_window_exceeded",
|
||||
"usage_limit_exceeded",
|
||||
"rate_limit_exceeded",
|
||||
"cyber_policy",
|
||||
"internal_server_error",
|
||||
"unauthorized",
|
||||
|
||||
@@ -51,6 +51,29 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"AutoReviewRequirements": {
|
||||
"properties": {
|
||||
"ignoreRules": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"requiredOnModels": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ConfigRequirements": {
|
||||
"properties": {
|
||||
"allowedApprovalPolicies": {
|
||||
@@ -80,6 +103,16 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"autoReview": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AutoReviewRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"enforceResidency": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"McpServerOauthClientRegistration": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"dcr"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"clientRegistration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/McpServerOauthClientRegistration"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Registration strategy for this login only; omission preserves automatic DCR."
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"rateLimitExceeded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* Codex errors that we expose to clients.
|
||||
*/
|
||||
export type CodexErrorInfo = "context_window_exceeded" | "usage_limit_exceeded" | "cyber_policy" | { "model_cap": { model: string, reset_after_seconds: bigint | null, } } | { "http_connection_failed": { http_status_code: number | null, } } | { "response_stream_connection_failed": { http_status_code: number | null, } } | "internal_server_error" | "unauthorized" | "bad_request" | "sandbox_error" | { "response_stream_disconnected": { http_status_code: number | null, } } | { "response_too_many_failed_attempts": { http_status_code: number | null, } } | "thread_rollback_failed" | "other";
|
||||
export type CodexErrorInfo = "context_window_exceeded" | "usage_limit_exceeded" | "rate_limit_exceeded" | "cyber_policy" | { "model_cap": { model: string, reset_after_seconds: bigint | null, } } | { "http_connection_failed": { http_status_code: number | null, } } | { "response_stream_connection_failed": { http_status_code: number | null, } } | "internal_server_error" | "unauthorized" | "bad_request" | "sandbox_error" | { "response_stream_disconnected": { http_status_code: number | null, } } | { "response_too_many_failed_attempts": { http_status_code: number | null, } } | "thread_rollback_failed" | "other";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AutoReviewRequirements = { requiredOnModels: Array<string> | null, ignoreRules: Array<string> | null, };
|
||||
@@ -8,4 +8,4 @@
|
||||
* When an upstream HTTP status is available (for example, from the Responses API or a provider),
|
||||
* it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
|
||||
*/
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "cyberPolicy" | { "modelCap": { model: string, reset_after_seconds: bigint | null, } } | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other";
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "cyberPolicy" | { "modelCap": { model: string, reset_after_seconds: bigint | null, } } | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other";
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { WebSearchMode } from "../WebSearchMode";
|
||||
import type { AskForApproval } from "./AskForApproval";
|
||||
import type { AutoReviewRequirements } from "./AutoReviewRequirements";
|
||||
import type { ResidencyRequirement } from "./ResidencyRequirement";
|
||||
import type { SandboxMode } from "./SandboxMode";
|
||||
|
||||
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, enforceResidency: ResidencyRequirement | null};
|
||||
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type McpServerOauthClientRegistration = "auto" | "dcr";
|
||||
@@ -1,5 +1,10 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { McpServerOauthClientRegistration } from "./McpServerOauthClientRegistration";
|
||||
|
||||
export type McpServerOauthLoginParams = { name: string, scopes?: Array<string> | null, timeoutSecs?: bigint | null, };
|
||||
export type McpServerOauthLoginParams = { name: string,
|
||||
/**
|
||||
* Registration strategy for this login only; omission preserves automatic DCR.
|
||||
*/
|
||||
clientRegistration?: McpServerOauthClientRegistration | null, scopes?: Array<string> | null, timeoutSecs?: bigint | null, };
|
||||
|
||||
@@ -16,6 +16,7 @@ export type { AppsConfig } from "./AppsConfig";
|
||||
export type { AppsListParams } from "./AppsListParams";
|
||||
export type { AppsListResponse } from "./AppsListResponse";
|
||||
export type { AskForApproval } from "./AskForApproval";
|
||||
export type { AutoReviewRequirements } from "./AutoReviewRequirements";
|
||||
export type { ByteRange } from "./ByteRange";
|
||||
export type { CancelLoginAccountParams } from "./CancelLoginAccountParams";
|
||||
export type { CancelLoginAccountResponse } from "./CancelLoginAccountResponse";
|
||||
@@ -93,6 +94,7 @@ export type { LoginAccountResponse } from "./LoginAccountResponse";
|
||||
export type { LoginAppBrand } from "./LoginAppBrand";
|
||||
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
|
||||
export type { McpAuthStatus } from "./McpAuthStatus";
|
||||
export type { McpServerOauthClientRegistration } from "./McpServerOauthClientRegistration";
|
||||
export type { McpServerOauthLoginCompletedNotification } from "./McpServerOauthLoginCompletedNotification";
|
||||
export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams";
|
||||
export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse";
|
||||
|
||||
@@ -603,17 +603,33 @@ fn json_files_in_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
|
||||
fn ensure_trailing_newlines(paths: &[PathBuf]) -> Result<()> {
|
||||
for path in paths {
|
||||
let mut content = fs::read_to_string(path)
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {path}", path = path.display()))?;
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
fs::write(path, content)
|
||||
let normalized = normalize_generated_text(&content);
|
||||
if normalized != content {
|
||||
fs::write(path, normalized)
|
||||
.with_context(|| format!("Failed to write {path}", path = path.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_generated_text(content: &str) -> String {
|
||||
let mut normalized = String::with_capacity(content.len());
|
||||
for segment in content.split_inclusive('\n') {
|
||||
if let Some(line) = segment.strip_suffix('\n') {
|
||||
normalized.push_str(line.trim_end_matches([' ', '\t']));
|
||||
normalized.push('\n');
|
||||
} else {
|
||||
normalized.push_str(segment.trim_end_matches([' ', '\t']));
|
||||
}
|
||||
}
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn read_json_value(path: &Path) -> Result<Value> {
|
||||
let content =
|
||||
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
|
||||
@@ -102,6 +102,7 @@ macro_rules! v2_enum_from_core {
|
||||
pub enum CodexErrorInfo {
|
||||
ContextWindowExceeded,
|
||||
UsageLimitExceeded,
|
||||
RateLimitExceeded,
|
||||
CyberPolicy,
|
||||
ModelCap {
|
||||
model: String,
|
||||
@@ -143,6 +144,7 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
|
||||
match value {
|
||||
CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
|
||||
CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded,
|
||||
CoreCodexErrorInfo::RateLimitExceeded => CodexErrorInfo::RateLimitExceeded,
|
||||
CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy,
|
||||
CoreCodexErrorInfo::ModelCap {
|
||||
model,
|
||||
@@ -573,6 +575,15 @@ pub struct ConfigRequirements {
|
||||
pub enforce_residency: Option<ResidencyRequirement>,
|
||||
#[experimental("configRequirements/read.network")]
|
||||
pub network: Option<NetworkRequirements>,
|
||||
pub auto_review: Option<AutoReviewRequirements>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct AutoReviewRequirements {
|
||||
pub required_on_models: Option<Vec<String>>,
|
||||
pub ignore_rules: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -1583,6 +1594,10 @@ pub struct McpServerRefreshResponse {}
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerOauthLoginParams {
|
||||
pub name: String,
|
||||
/// Registration strategy for this login only; omission preserves automatic DCR.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub client_registration: Option<McpServerOauthClientRegistration>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub scopes: Option<Vec<String>>,
|
||||
@@ -1591,6 +1606,15 @@ pub struct McpServerOauthLoginParams {
|
||||
pub timeout_secs: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase", export_to = "v2/")]
|
||||
pub enum McpServerOauthClientRegistration {
|
||||
#[default]
|
||||
Auto,
|
||||
Dcr,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -85,12 +85,28 @@ fn read_file_bytes(path: &Path) -> Result<Vec<u8>> {
|
||||
// fixture test is platform-independent.
|
||||
let text = String::from_utf8(bytes)
|
||||
.with_context(|| format!("expected UTF-8 TypeScript in {}", path.display()))?;
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let text = normalize_generated_text(&text.replace("\r\n", "\n").replace('\r', "\n"));
|
||||
return Ok(text.into_bytes());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn normalize_generated_text(content: &str) -> String {
|
||||
let mut normalized = String::with_capacity(content.len());
|
||||
for segment in content.split_inclusive('\n') {
|
||||
if let Some(line) = segment.strip_suffix('\n') {
|
||||
normalized.push_str(line.trim_end_matches([' ', '\t']));
|
||||
normalized.push('\n');
|
||||
} else {
|
||||
normalized.push_str(segment.trim_end_matches([' ', '\t']));
|
||||
}
|
||||
}
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn canonicalize_json(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
@@ -234,4 +250,3 @@ mod tests {
|
||||
assert_eq!(canonicalize_json(&value), expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -346,6 +346,7 @@ impl MessageProcessor {
|
||||
allowed_web_search_modes: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
auto_review: None,
|
||||
}),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
|
||||
@@ -357,6 +357,15 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
|
||||
let mut changes = HashMap::new();
|
||||
for hunk in hunks {
|
||||
let path = hunk.resolve_path(&effective_cwd);
|
||||
if changes.contains_key(&path) {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(
|
||||
ParseError::InvalidPatchError(format!(
|
||||
"multiple operations target {}",
|
||||
path.display()
|
||||
))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
match hunk {
|
||||
Hunk::AddFile { contents, .. } => {
|
||||
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
||||
@@ -1841,6 +1850,37 @@ g
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_rejects_duplicate_resolved_paths() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
fs::write(session_dir.path().join("target.txt"), "one\ntwo\n").unwrap();
|
||||
|
||||
let argv = vec![
|
||||
"apply_patch".to_string(),
|
||||
r#"*** Begin Patch
|
||||
*** Update File: target.txt
|
||||
@@
|
||||
-one
|
||||
+ONE
|
||||
*** Update File: target.txt
|
||||
@@
|
||||
-two
|
||||
+TWO
|
||||
*** End Patch"#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
maybe_parse_apply_patch_verified(&argv, session_dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ParseError(
|
||||
ParseError::InvalidPatchError(format!(
|
||||
"multiple operations target {}",
|
||||
session_dir.path().join("target.txt").display()
|
||||
))
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_fails_on_write_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
@@ -13,7 +13,7 @@ path = "src/main.rs"
|
||||
# program — an alias, never the canonical name.
|
||||
[[bin]]
|
||||
name = "code"
|
||||
path = "src/main.rs"
|
||||
path = "src/code_alias.rs"
|
||||
|
||||
[lib]
|
||||
name = "code_cli"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Compatibility entry point for existing `code` installs.
|
||||
include!("main.rs");
|
||||
@@ -259,6 +259,12 @@ mod tests {
|
||||
_ => panic!("expected streamable http transport"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_style_server_name_is_valid() {
|
||||
assert!(validate_server_name("npm:@modelcontextprotocol/server-sequential.thinking").is_ok());
|
||||
assert!(validate_server_name("npm:@scope/package name").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> {
|
||||
@@ -591,11 +597,11 @@ fn validate_server_name(name: &str) -> Result<()> {
|
||||
let is_valid = !name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '@' | '/' | '.'));
|
||||
|
||||
if is_valid {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("invalid server name '{name}' (use letters, numbers, '-', '_')");
|
||||
bail!("invalid server name '{name}' (use letters, numbers, '-', '_', ':', '@', '/', '.')");
|
||||
}
|
||||
}
|
||||
|
||||
+14
-50
@@ -218,11 +218,10 @@ fn is_quota_exceeded_http_error(status: StatusCode, error: &Error) -> bool {
|
||||
|
||||
fn should_store_responses(
|
||||
prompt: &Prompt,
|
||||
provider: &ModelProviderInfo,
|
||||
_provider: &ModelProviderInfo,
|
||||
request_family: &ModelFamily,
|
||||
) -> bool {
|
||||
provider.is_azure_responses_endpoint()
|
||||
|| (!request_family.use_responses_lite && prompt.store)
|
||||
!request_family.use_responses_lite && prompt.store
|
||||
}
|
||||
|
||||
fn is_server_overloaded_error(error: &Error) -> bool {
|
||||
@@ -878,9 +877,6 @@ impl ModelClient {
|
||||
if let Some(model_value) = payload_json.get_mut("model") {
|
||||
*model_value = serde_json::Value::String(model_slug.to_string());
|
||||
}
|
||||
if self.provider.is_azure_responses_endpoint() {
|
||||
attach_item_ids(&mut payload_json, &input_with_instructions);
|
||||
}
|
||||
if let Some(openrouter_cfg) = self.provider.openrouter_config() {
|
||||
if let Some(obj) = payload_json.as_object_mut() {
|
||||
if let Some(provider) = &openrouter_cfg.provider {
|
||||
@@ -1327,15 +1323,6 @@ impl ModelClient {
|
||||
(_, None, None) => None,
|
||||
};
|
||||
|
||||
// In general, we want to explicitly send `store: false` when using the Responses API,
|
||||
// but in practice, the Azure Responses API rejects `store: false`:
|
||||
//
|
||||
// - If store = false and id is sent an error is thrown that ID is not found
|
||||
// - If store = false and id is not sent an error is thrown that ID is required
|
||||
//
|
||||
// For Azure, we send `store: true` and preserve reasoning item IDs.
|
||||
let azure_workaround = self.provider.is_azure_responses_endpoint();
|
||||
|
||||
let model_slug = request_model;
|
||||
|
||||
let session_id = prompt
|
||||
@@ -1400,9 +1387,6 @@ impl ModelClient {
|
||||
if let Some(model_value) = payload_json.get_mut("model") {
|
||||
*model_value = serde_json::Value::String(model_slug.to_string());
|
||||
}
|
||||
if azure_workaround {
|
||||
attach_item_ids(&mut payload_json, &input_with_instructions);
|
||||
}
|
||||
if let Some(openrouter_cfg) = self.provider.openrouter_config() {
|
||||
if let Some(obj) = payload_json.as_object_mut() {
|
||||
if let Some(provider) = &openrouter_cfg.provider {
|
||||
@@ -2529,34 +2513,6 @@ struct ResponseCompletedOutputTokensDetails {
|
||||
reasoning_tokens: u64,
|
||||
}
|
||||
|
||||
fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
|
||||
let Some(input_value) = payload_json.get_mut("input") else {
|
||||
return;
|
||||
};
|
||||
let serde_json::Value::Array(items) = input_value else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (value, item) in items.iter_mut().zip(original_items.iter()) {
|
||||
let id = match item {
|
||||
ResponseItem::AdditionalTools { id, .. }
|
||||
| ResponseItem::Reasoning { id, .. }
|
||||
| ResponseItem::Message { id, .. }
|
||||
| ResponseItem::WebSearchCall { id, .. }
|
||||
| ResponseItem::FunctionCall { id, .. }
|
||||
| ResponseItem::LocalShellCall { id, .. }
|
||||
| ResponseItem::CustomToolCall { id, .. } => id.as_deref(),
|
||||
ResponseItem::ImageGenerationCall { id, .. } => Some(id.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|id| !id.is_empty());
|
||||
|
||||
if let (Some(id), Some(obj)) = (id, value.as_object_mut()) {
|
||||
obj.insert("id".to_string(), Value::String(id.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_response_items_for_request(input: &mut [ResponseItem]) {
|
||||
for item in input {
|
||||
match item {
|
||||
@@ -3082,6 +3038,14 @@ async fn process_sse<S>(
|
||||
));
|
||||
} else if error.r#type.as_deref() == Some("usage_not_included") {
|
||||
response_error = Some(CodexErr::UsageNotIncluded);
|
||||
} else if error.code.as_deref() == Some("rate_limit_exceeded") {
|
||||
let retry_after = try_parse_retry_after(&error, Utc::now());
|
||||
let message = error.message.unwrap_or_default();
|
||||
response_error = Some(CodexErr::RateLimitExceeded(
|
||||
message,
|
||||
retry_after,
|
||||
Some(request_id.clone()),
|
||||
));
|
||||
} else if is_quota_exceeded_error(&error) {
|
||||
response_error = Some(CodexErr::QuotaExceeded);
|
||||
} else if is_server_overloaded_error(&error) {
|
||||
@@ -3361,15 +3325,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_storage_is_forced_for_azure_provider() {
|
||||
fn responses_storage_is_not_forced_for_azure_provider() {
|
||||
let prompt = Prompt::default();
|
||||
let mut family = derive_default_model_family("gpt-test");
|
||||
let azure_provider = responses_test_provider("azure", WireApi::Responses);
|
||||
|
||||
assert!(should_store_responses(&prompt, &azure_provider, &family));
|
||||
assert!(!should_store_responses(&prompt, &azure_provider, &family));
|
||||
|
||||
family.use_responses_lite = true;
|
||||
assert!(should_store_responses(&prompt, &azure_provider, &family));
|
||||
assert!(!should_store_responses(&prompt, &azure_provider, &family));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4054,7 +4018,7 @@ mod tests {
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
match &events[0] {
|
||||
Err(CodexErr::Stream(msg, Some(retry), _)) => {
|
||||
Err(CodexErr::RateLimitExceeded(msg, Some(retry), _)) => {
|
||||
assert_eq!(
|
||||
msg,
|
||||
"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."
|
||||
|
||||
@@ -635,6 +635,7 @@ pub(super) fn is_context_overflow_error(err: &CodexErr) -> bool {
|
||||
match err {
|
||||
CodexErr::UnexpectedStatus(resp) => looks_like_context_overflow(&resp.body),
|
||||
CodexErr::Stream(msg, _, _) => looks_like_context_overflow(msg),
|
||||
CodexErr::RateLimitExceeded(msg, _, _) => looks_like_context_overflow(msg),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ pub(super) fn format_retry_eta(retry_after: &RetryAfter) -> Option<String> {
|
||||
pub(super) fn is_connectivity_error(err: &CodexErr) -> bool {
|
||||
match err {
|
||||
CodexErr::Reqwest(e) => e.is_connect() || e.is_timeout() || e.is_request(),
|
||||
CodexErr::RateLimitExceeded(_, _, _) => false,
|
||||
CodexErr::Stream(msg, _, _) => {
|
||||
let lower = msg.to_ascii_lowercase();
|
||||
(msg.starts_with("[transport]")
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::auth_accounts;
|
||||
use crate::account_switching::RateLimitSwitchState;
|
||||
use crate::agent_tool::current_agent_spawn_depth;
|
||||
use crate::agent_tool::external_agent_command_exists;
|
||||
use crate::exec_env::inject_session_id_env;
|
||||
use crate::protocol::McpListToolsResponseEvent;
|
||||
use crate::protocol::TaskLifecycleEvent;
|
||||
use crate::protocol::TaskLifecyclePhase;
|
||||
@@ -3493,7 +3494,9 @@ async fn run_turn(
|
||||
// Use the configured provider-specific stream retry budget.
|
||||
let max_retries = tc.client.get_provider().stream_max_retries();
|
||||
let req_id = match &e {
|
||||
CodexErr::Stream(_, _, req) => req.clone(),
|
||||
CodexErr::Stream(_, _, req) | CodexErr::RateLimitExceeded(_, _, req) => {
|
||||
req.clone()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let is_connectivity = is_connectivity_error(&e);
|
||||
@@ -3606,7 +3609,8 @@ async fn run_turn(
|
||||
if should_retry_stream_after_error(has_tool_responses, retries, max_retries) {
|
||||
retries += 1;
|
||||
let (delay, retry_eta) = match e {
|
||||
CodexErr::Stream(_, Some(ref retry_after), _) => {
|
||||
CodexErr::Stream(_, Some(ref retry_after), _)
|
||||
| CodexErr::RateLimitExceeded(_, Some(ref retry_after), _) => {
|
||||
let eta = format_retry_eta(&retry_after);
|
||||
(retry_after.delay, eta)
|
||||
}
|
||||
@@ -5257,15 +5261,19 @@ fn parse_apply_patch_arguments(
|
||||
call_id: &str,
|
||||
) -> Result<ExecParams, Box<ResponseInputItem>> {
|
||||
match parse_apply_patch_input(&arguments) {
|
||||
Ok(input) => Ok(ExecParams {
|
||||
command: vec!["apply_patch".to_string(), input],
|
||||
shell_script: None,
|
||||
cwd: sess.get_cwd().to_path_buf(),
|
||||
timeout_ms: None,
|
||||
env: HashMap::new(),
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
}),
|
||||
Ok(input) => {
|
||||
let mut env = HashMap::new();
|
||||
inject_session_id_env(&mut env, sess.id);
|
||||
Ok(ExecParams {
|
||||
command: vec!["apply_patch".to_string(), input],
|
||||
shell_script: None,
|
||||
cwd: sess.get_cwd().to_path_buf(),
|
||||
timeout_ms: None,
|
||||
env,
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
let output = ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
@@ -8256,12 +8264,14 @@ fn to_exec_params(params: ShellToolCallParams, sess: &Session) -> ExecParams {
|
||||
let with_escalated_permissions = params
|
||||
.sandbox_permissions
|
||||
.and_then(|p| p.requires_escalated_permissions().then_some(true));
|
||||
let mut env = create_env(&sess.shell_environment_policy);
|
||||
inject_session_id_env(&mut env, sess.id);
|
||||
ExecParams {
|
||||
command: params.command,
|
||||
shell_script: None,
|
||||
cwd: sess.resolve_path(params.workdir.clone()),
|
||||
timeout_ms,
|
||||
env: create_env(&sess.shell_environment_policy),
|
||||
env,
|
||||
with_escalated_permissions,
|
||||
justification: params.justification,
|
||||
}
|
||||
@@ -8273,6 +8283,8 @@ fn to_exec_params_from_shell_command(params: ShellCommandToolCallParams, sess: &
|
||||
.sandbox_permissions
|
||||
.and_then(|p| p.requires_escalated_permissions().then_some(true));
|
||||
let use_login_shell = params.login.unwrap_or(true);
|
||||
let mut env = create_env(&sess.shell_environment_policy);
|
||||
inject_session_id_env(&mut env, sess.id);
|
||||
|
||||
ExecParams {
|
||||
command: vec![params.command.clone()],
|
||||
@@ -8282,7 +8294,7 @@ fn to_exec_params_from_shell_command(params: ShellCommandToolCallParams, sess: &
|
||||
}),
|
||||
cwd: sess.resolve_path(params.workdir.clone()),
|
||||
timeout_ms,
|
||||
env: create_env(&sess.shell_environment_policy),
|
||||
env,
|
||||
with_escalated_permissions,
|
||||
justification: params.justification,
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ pub enum CodexErr {
|
||||
#[error("stream disconnected before completion: {0}")]
|
||||
Stream(String, Option<RetryAfter>, Option<String>),
|
||||
|
||||
/// A retryable upstream rate limit received inside the response stream.
|
||||
#[error("rate limit exceeded: {0}")]
|
||||
RateLimitExceeded(String, Option<RetryAfter>, Option<String>),
|
||||
|
||||
#[error("no conversation with id: {0}")]
|
||||
ConversationNotFound(Uuid),
|
||||
|
||||
|
||||
@@ -3,6 +3,22 @@ use crate::config_types::ShellEnvironmentPolicy;
|
||||
use crate::config_types::ShellEnvironmentPolicyInherit;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const CODEX_SESSION_ID_ENV_VAR: &str = "CODEX_SESSION_ID";
|
||||
pub const OPENAI_FEDERATION_RULE_ID_ENV_VAR: &str = "OPENAI_FEDERATION_RULE_ID";
|
||||
pub const OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR: &str = "OPENAI_IDENTITY_TOKEN_FILE";
|
||||
|
||||
const NON_INHERITABLE_ENV_VARS: &[&str] = &[
|
||||
OPENAI_FEDERATION_RULE_ID_ENV_VAR,
|
||||
OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR,
|
||||
];
|
||||
|
||||
pub fn is_non_inheritable_env_var(name: &str) -> bool {
|
||||
NON_INHERITABLE_ENV_VARS
|
||||
.iter()
|
||||
.any(|restricted| restricted.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Construct an environment map based on the rules in the specified policy. The
|
||||
/// resulting map can be passed directly to `Command::envs()` after calling
|
||||
@@ -15,6 +31,11 @@ pub fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap<String, String> {
|
||||
populate_env(std::env::vars(), policy)
|
||||
}
|
||||
|
||||
/// Exposes the shared session identity to model-reachable shell commands.
|
||||
pub(crate) fn inject_session_id_env(env: &mut HashMap<String, String>, session_id: Uuid) {
|
||||
env.insert(CODEX_SESSION_ID_ENV_VAR.to_string(), session_id.to_string());
|
||||
}
|
||||
|
||||
fn populate_env<I>(vars: I, policy: &ShellEnvironmentPolicy) -> HashMap<String, String>
|
||||
where
|
||||
I: IntoIterator<Item = (String, String)>,
|
||||
@@ -94,6 +115,10 @@ where
|
||||
env_map.retain(|k, _| matches_any(k, &policy.include_only));
|
||||
}
|
||||
|
||||
// Restricted launch context must never reach model-executed child processes,
|
||||
// even when inherited or restored through explicit shell environment overrides.
|
||||
env_map.retain(|name, _| !is_non_inheritable_env_var(name));
|
||||
|
||||
env_map
|
||||
}
|
||||
|
||||
@@ -166,6 +191,27 @@ mod tests {
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_session_id_env_overrides_policy_filtering() {
|
||||
let session_id = Uuid::new_v4();
|
||||
let mut env = populate_env(
|
||||
make_vars(&[("PATH", "/usr/bin")]),
|
||||
&ShellEnvironmentPolicy {
|
||||
ignore_default_excludes: true,
|
||||
include_only: vec![EnvironmentVariablePattern::new_case_insensitive("PATH")],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
inject_session_id_env(&mut env, session_id);
|
||||
let expected = session_id.to_string();
|
||||
|
||||
assert_eq!(
|
||||
env.get(CODEX_SESSION_ID_ENV_VAR).map(String::as_str),
|
||||
Some(expected.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_overrides() {
|
||||
let vars = make_vars(&[("PATH", "/usr/bin")]);
|
||||
@@ -199,7 +245,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_inherit_all() {
|
||||
let vars = make_vars(&[("PATH", "/usr/bin"), ("FOO", "bar")]);
|
||||
let vars = make_vars(&[
|
||||
("PATH", "/usr/bin"),
|
||||
("FOO", "bar"),
|
||||
(OPENAI_FEDERATION_RULE_ID_ENV_VAR, "rule"),
|
||||
(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR, "/tmp/token"),
|
||||
]);
|
||||
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::All,
|
||||
@@ -217,11 +268,39 @@ mod tests {
|
||||
expected.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string());
|
||||
expected.insert("LANG".to_string(), "C.UTF-8".to_string());
|
||||
expected.insert("LC_ALL".to_string(), "C.UTF-8".to_string());
|
||||
expected.remove(OPENAI_FEDERATION_RULE_ID_ENV_VAR);
|
||||
expected.remove(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR);
|
||||
#[cfg(unix)]
|
||||
expected.insert("GIT_ASKPASS".to_string(), "true".to_string());
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_inheritable_env_vars_cannot_be_restored_by_overrides() {
|
||||
let vars = make_vars(&[
|
||||
(OPENAI_FEDERATION_RULE_ID_ENV_VAR, "inherited-rule"),
|
||||
(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR, "/tmp/inherited-token"),
|
||||
]);
|
||||
let mut policy = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::All,
|
||||
ignore_default_excludes: true,
|
||||
..Default::default()
|
||||
};
|
||||
policy.r#set.insert(
|
||||
OPENAI_FEDERATION_RULE_ID_ENV_VAR.to_string(),
|
||||
"override-rule".to_string(),
|
||||
);
|
||||
policy.r#set.insert(
|
||||
OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR.to_string(),
|
||||
"/tmp/override-token".to_string(),
|
||||
);
|
||||
|
||||
let result = populate_env(vars, &policy);
|
||||
|
||||
assert!(!result.contains_key(OPENAI_FEDERATION_RULE_ID_ENV_VAR));
|
||||
assert!(!result.contains_key(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inherit_all_with_default_excludes() {
|
||||
let vars = make_vars(&[("PATH", "/usr/bin"), ("API_KEY", "secret")]);
|
||||
|
||||
@@ -283,7 +283,7 @@ impl McpConnectionManager {
|
||||
// Validate server name before spawning
|
||||
if !is_valid_mcp_server_name(&server_name) {
|
||||
let message = format!(
|
||||
"invalid server name '{server_name}': must match pattern ^[a-zA-Z0-9_-]+$"
|
||||
"invalid server name '{server_name}': must match pattern ^[a-zA-Z0-9_:@/.-]+$"
|
||||
);
|
||||
errors.insert(
|
||||
server_name,
|
||||
@@ -598,7 +598,7 @@ fn is_valid_mcp_server_name(server_name: &str) -> bool {
|
||||
!server_name.is_empty()
|
||||
&& server_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '@' | '/' | '.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -632,6 +632,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_style_server_name_is_valid() {
|
||||
assert!(is_valid_mcp_server_name(
|
||||
"npm:@modelcontextprotocol/server-sequential.thinking"
|
||||
));
|
||||
assert!(!is_valid_mcp_server_name("npm:@scope/package name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qualify_tools_short_non_duplicated_names() {
|
||||
let tools = vec![
|
||||
|
||||
@@ -33,14 +33,42 @@ const CONTEXT_WINDOW_128K: u64 = 128_000;
|
||||
const CONTEXT_WINDOW_96K: u64 = 96_000;
|
||||
const CONTEXT_WINDOW_16K: u64 = 16_385;
|
||||
const CONTEXT_WINDOW_1M: u64 = 1_047_576;
|
||||
const CONTEXT_WINDOW_QWEN3_CODER_30B: u64 = 262_144;
|
||||
const MAX_OUTPUT_DEFAULT: u64 = 128_000;
|
||||
const MAX_OUTPUT_QWEN3_CODER_30B: u64 = 65_536;
|
||||
|
||||
static UPSTREAM_MODELS: Lazy<Vec<ModelInfo>> = Lazy::new(|| {
|
||||
serde_json::from_str::<ModelsResponse>(include_str!("../../../vendor/codex/models-manager/models.json"))
|
||||
.map(|response| response.models)
|
||||
.unwrap_or_else(|err| panic!("failed to parse upstream models.json: {err}"))
|
||||
parse_upstream_models(include_str!(
|
||||
"../../../vendor/codex/models-manager/models.json"
|
||||
))
|
||||
.unwrap_or_else(|err| panic!("failed to parse upstream models.json: {err}"))
|
||||
});
|
||||
|
||||
fn parse_upstream_models(raw_catalog: &str) -> serde_json::Result<Vec<ModelInfo>> {
|
||||
let mut catalog: serde_json::Value = serde_json::from_str(raw_catalog)?;
|
||||
normalize_reasoning_summary_field_names(&mut catalog);
|
||||
serde_json::from_value::<ModelsResponse>(catalog).map(|response| response.models)
|
||||
}
|
||||
|
||||
fn normalize_reasoning_summary_field_names(catalog: &mut serde_json::Value) {
|
||||
let Some(models) = catalog.get_mut("models").and_then(serde_json::Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for model in models {
|
||||
let Some(model) = model.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some(supports_summary_parameter) =
|
||||
model.remove("supports_reasoning_summary_parameter")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
model
|
||||
.entry("supports_reasoning_summaries")
|
||||
.or_insert(supports_summary_parameter);
|
||||
}
|
||||
}
|
||||
|
||||
fn namespaced_model_suffix(model: &str) -> Option<&str> {
|
||||
let (namespace, suffix) = model.split_once('/')?;
|
||||
if suffix.contains('/') {
|
||||
@@ -248,8 +276,10 @@ fn apply_upstream_model_overrides(mut family: ModelFamily) -> ModelFamily {
|
||||
family.supports_image_detail_original = model_info.supports_image_detail_original;
|
||||
family.supports_image_generation = supports_image_generation(model_info);
|
||||
family.uses_local_shell_tool = matches!(model_info.shell_type, ConfigShellToolType::Local);
|
||||
family.uses_shell_command_tool =
|
||||
matches!(model_info.shell_type, ConfigShellToolType::ShellCommand);
|
||||
family.uses_shell_command_tool = matches!(
|
||||
model_info.shell_type,
|
||||
ConfigShellToolType::ShellCommand | ConfigShellToolType::UnifiedExec
|
||||
);
|
||||
family.auto_compact_token_limit = model_info.auto_compact_token_limit();
|
||||
family.truncation_policy = match model_info.truncation_policy.mode {
|
||||
TruncationMode::Bytes => TruncationPolicy::Bytes(
|
||||
@@ -273,7 +303,16 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
|
||||
return Some(family);
|
||||
}
|
||||
|
||||
if slug.starts_with("o3") {
|
||||
let normalized_slug = slug.to_ascii_lowercase();
|
||||
|
||||
if normalized_slug.starts_with("qwen3-coder-30b-a3b-instruct") {
|
||||
model_family!(
|
||||
slug, "qwen3-coder-30b-a3b-instruct",
|
||||
uses_shell_command_tool: true,
|
||||
context_window: Some(CONTEXT_WINDOW_QWEN3_CODER_30B),
|
||||
max_output_tokens: Some(MAX_OUTPUT_QWEN3_CODER_30B),
|
||||
)
|
||||
} else if slug.starts_with("o3") {
|
||||
model_family!(
|
||||
slug, "o3",
|
||||
supports_reasoning_summaries: true,
|
||||
@@ -510,6 +549,7 @@ mod tests {
|
||||
use crate::tool_apply_patch::ApplyPatchToolType;
|
||||
|
||||
use super::find_family_for_model;
|
||||
use super::parse_upstream_models;
|
||||
|
||||
#[test]
|
||||
fn image_generation_support_tracks_image_input_modality() {
|
||||
@@ -540,6 +580,25 @@ mod tests {
|
||||
Some(ReasoningEffort::Medium)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_models_parser_accepts_renamed_reasoning_summary_field() {
|
||||
let catalog = include_str!("../../../vendor/codex/models-manager/models.json");
|
||||
let models = parse_upstream_models(catalog).expect("bundled upstream models parse");
|
||||
|
||||
assert!(!models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen3_coder_30b_quantized_slug_has_explicit_local_metadata() {
|
||||
let family = find_family_for_model("Qwen3-Coder-30B-A3B-Instruct-Q4_K_M")
|
||||
.expect("Qwen3 Coder 30B local quantized slug should resolve");
|
||||
|
||||
assert_eq!(family.family, "qwen3-coder-30b-a3b-instruct");
|
||||
assert_eq!(family.context_window, Some(262_144));
|
||||
assert_eq!(family.max_output_tokens, Some(65_536));
|
||||
assert!(family.uses_shell_command_tool);
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelFamily {
|
||||
|
||||
@@ -1046,6 +1046,7 @@ pub enum AgentStatus {
|
||||
pub enum CodexErrorInfo {
|
||||
ContextWindowExceeded,
|
||||
UsageLimitExceeded,
|
||||
RateLimitExceeded,
|
||||
CyberPolicy,
|
||||
ModelCap {
|
||||
model: String,
|
||||
|
||||
Vendored
+5
@@ -36,6 +36,11 @@ max-threads = 2
|
||||
filter = 'test(rmcp_client) | test(humanlike_typing_1000_chars_appears_live_no_placeholder)'
|
||||
slow-timeout = { period = "1m", terminate-after = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
# This end-to-end case launches several CLI subprocesses and checks cloud policy.
|
||||
filter = 'package(codex-exec) & test(worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume)'
|
||||
slow-timeout = { period = "30s", terminate-after = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'test(approval_matrix_covers_all_modes)'
|
||||
slow-timeout = { period = "30s", terminate-after = 2 }
|
||||
|
||||
+1680
-228
File diff suppressed because it is too large
Load Diff
Vendored
+42
-12
@@ -4,9 +4,12 @@ members = [
|
||||
"analytics",
|
||||
"agent-graph-store",
|
||||
"agent-identity",
|
||||
"agent-roles",
|
||||
"backend-client",
|
||||
"bwrap",
|
||||
"build-info",
|
||||
"ansi-escape",
|
||||
"attachment-store",
|
||||
"async-utils",
|
||||
"app-server",
|
||||
"app-server-transport",
|
||||
@@ -34,6 +37,7 @@ members = [
|
||||
"collaboration-mode-templates",
|
||||
"connectors",
|
||||
"config",
|
||||
"config-schema",
|
||||
"context-fragments",
|
||||
"shell-command",
|
||||
"shell-escalation",
|
||||
@@ -41,9 +45,10 @@ members = [
|
||||
"core",
|
||||
"core-api",
|
||||
"core-plugins",
|
||||
"core-skills",
|
||||
"diagnostics",
|
||||
"guardian-context",
|
||||
"hooks",
|
||||
"history",
|
||||
"http-client",
|
||||
"secrets",
|
||||
"exec",
|
||||
@@ -57,7 +62,8 @@ members = [
|
||||
"ext/extension-api",
|
||||
"ext/goal",
|
||||
"ext/git-attribution",
|
||||
"ext/guardian",
|
||||
"ext/guardian-v2",
|
||||
"ext/history-notes",
|
||||
"ext/image-generation",
|
||||
"ext/items",
|
||||
"ext/memories",
|
||||
@@ -73,14 +79,16 @@ members = [
|
||||
"lmstudio",
|
||||
"login",
|
||||
"codex-mcp",
|
||||
"mcp-server",
|
||||
"memories/read",
|
||||
"memories/write",
|
||||
"model-provider-info",
|
||||
"mxc-sandbox",
|
||||
"models-manager",
|
||||
"network-proxy",
|
||||
"ollama",
|
||||
"process-hardening",
|
||||
"realtime-webrtc",
|
||||
"voice-host",
|
||||
"protocol",
|
||||
"prompts",
|
||||
"rollout",
|
||||
@@ -91,21 +99,27 @@ members = [
|
||||
"sandboxing",
|
||||
"stdio-to-uds",
|
||||
"otel",
|
||||
"otel-trace-websocket",
|
||||
"tui",
|
||||
"tools",
|
||||
"v8-poc",
|
||||
"websocket-client",
|
||||
"windows-sandbox-service",
|
||||
"worktree",
|
||||
"workload-identity",
|
||||
"utils/absolute-path",
|
||||
"utils/audio",
|
||||
"utils/path-uri",
|
||||
"utils/cargo-bin",
|
||||
"git-utils",
|
||||
"utils/cache",
|
||||
"utils/git-discovery",
|
||||
"utils/image",
|
||||
"utils/json-to-toml",
|
||||
"utils/home-dir",
|
||||
"utils/pty",
|
||||
"utils/readiness",
|
||||
"utils/redacted-string",
|
||||
"utils/rustls-provider",
|
||||
"utils/string",
|
||||
"utils/cli",
|
||||
@@ -145,12 +159,15 @@ license = "Apache-2.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal
|
||||
codex-realtime-webrtc = { path = "realtime-webrtc" }
|
||||
app_test_support = { path = "app-server/tests/common" }
|
||||
codex-analytics = { path = "analytics" }
|
||||
codex-agent-graph-store = { path = "agent-graph-store" }
|
||||
codex-agent-identity = { path = "agent-identity" }
|
||||
codex-agent-roles = { path = "agent-roles" }
|
||||
codex-ansi-escape = { path = "ansi-escape" }
|
||||
codex-api = { path = "codex-api" }
|
||||
codex-attachment-store = { path = "attachment-store" }
|
||||
codex-aws-auth = { path = "aws-auth" }
|
||||
codex-app-server = { path = "app-server" }
|
||||
codex-app-server-transport = { path = "app-server-transport" }
|
||||
@@ -163,8 +180,8 @@ codex-apply-patch = { path = "apply-patch" }
|
||||
codex-arg0 = { path = "arg0" }
|
||||
codex-async-utils = { path = "async-utils" }
|
||||
codex-backend-client = { path = "backend-client" }
|
||||
codex-build-info = { path = "build-info" }
|
||||
codex-chatgpt = { path = "chatgpt" }
|
||||
codex-cli = { path = "cli" }
|
||||
codex-client = { path = "codex-client" }
|
||||
codex-collaboration-mode-templates = { path = "collaboration-mode-templates" }
|
||||
codex-cloud-config = { path = "cloud-config" }
|
||||
@@ -184,7 +201,6 @@ codex-context-fragments = { path = "context-fragments" }
|
||||
codex-core = { path = "core" }
|
||||
codex-core-api = { path = "core-api" }
|
||||
codex-core-plugins = { path = "core-plugins" }
|
||||
codex-core-skills = { path = "core-skills" }
|
||||
codex-diagnostics = { path = "diagnostics" }
|
||||
codex-exec = { path = "exec" }
|
||||
codex-file-system = { path = "file-system" }
|
||||
@@ -196,7 +212,8 @@ codex-extension-api = { path = "ext/extension-api" }
|
||||
codex-extension-items = { path = "ext/items" }
|
||||
codex-goal-extension = { path = "ext/goal" }
|
||||
codex-git-attribution = { path = "ext/git-attribution" }
|
||||
codex-guardian = { path = "ext/guardian" }
|
||||
codex-guardian-v2 = { path = "ext/guardian-v2" }
|
||||
codex-history-notes-extension = { path = "ext/history-notes" }
|
||||
codex-image-generation-extension = { path = "ext/image-generation" }
|
||||
codex-external-agent-migration = { path = "external-agent-migration" }
|
||||
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
|
||||
@@ -206,7 +223,9 @@ codex-install-context = { path = "install-context" }
|
||||
codex-file-search = { path = "file-search" }
|
||||
codex-file-watcher = { path = "file-watcher" }
|
||||
codex-git-utils = { path = "git-utils" }
|
||||
codex-guardian-context = { path = "guardian-context" }
|
||||
codex-hooks = { path = "hooks" }
|
||||
codex-history = { path = "history" }
|
||||
codex-keyring-store = { path = "keyring-store" }
|
||||
codex-linux-sandbox = { path = "linux-sandbox" }
|
||||
codex-lmstudio = { path = "lmstudio" }
|
||||
@@ -218,12 +237,13 @@ codex-memories-read = { path = "memories/read" }
|
||||
codex-memories-write = { path = "memories/write" }
|
||||
codex-mcp = { path = "codex-mcp" }
|
||||
codex-mcp-extension = { path = "ext/mcp" }
|
||||
codex-mcp-server = { path = "mcp-server" }
|
||||
codex-model-provider-info = { path = "model-provider-info" }
|
||||
codex-models-manager = { path = "models-manager" }
|
||||
codex-mxc-sandbox = { path = "mxc-sandbox" }
|
||||
codex-network-proxy = { path = "network-proxy" }
|
||||
codex-ollama = { path = "ollama" }
|
||||
codex-otel = { path = "otel" }
|
||||
codex-otel-trace-websocket = { path = "otel-trace-websocket" }
|
||||
codex-plugin = { path = "plugin" }
|
||||
codex-model-provider = { path = "model-provider" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
@@ -257,6 +277,7 @@ codex-utils-cargo-bin = { path = "utils/cargo-bin" }
|
||||
codex-utils-cli = { path = "utils/cli" }
|
||||
codex-utils-elapsed = { path = "utils/elapsed" }
|
||||
codex-utils-fuzzy-match = { path = "utils/fuzzy-match" }
|
||||
codex-utils-git-discovery = { path = "utils/git-discovery" }
|
||||
codex-utils-home-dir = { path = "utils/home-dir" }
|
||||
codex-utils-image = { path = "utils/image" }
|
||||
codex-utils-json-to-toml = { path = "utils/json-to-toml" }
|
||||
@@ -266,21 +287,26 @@ codex-utils-path = { path = "utils/path-utils" }
|
||||
codex-utils-path-uri = { path = "utils/path-uri" }
|
||||
codex-utils-plugins = { path = "utils/plugins" }
|
||||
codex-utils-pty = { path = "utils/pty" }
|
||||
codex-utils-redacted-string = { path = "utils/redacted-string" }
|
||||
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
|
||||
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
|
||||
codex-worktree = { path = "worktree" }
|
||||
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
|
||||
codex-utils-stream-parser = { path = "utils/stream-parser" }
|
||||
codex-utils-string = { path = "utils/string" }
|
||||
codex-utils-template = { path = "utils/template" }
|
||||
codex-v8-poc = { path = "v8-poc" }
|
||||
codex-workload-identity = { path = "workload-identity" }
|
||||
codex-windows-sandbox = { path = "windows-sandbox-rs" }
|
||||
core_test_support = { path = "core/tests/common" }
|
||||
mcp_test_support = { path = "mcp-server/tests/common" }
|
||||
|
||||
# External
|
||||
learning_mode_windows = { git = "https://github.com/microsoft/mxc", rev = "6cd3d58f05d3447e67109cfb75e042803b843ca4" }
|
||||
wxc_common = { git = "https://github.com/microsoft/mxc", rev = "6cd3d58f05d3447e67109cfb75e042803b843ca4" }
|
||||
age = "0.11.1"
|
||||
ansi-to-tui = "8.0.1"
|
||||
anyhow = "1"
|
||||
appcontainer_common = { git = "https://github.com/microsoft/mxc", rev = "6cd3d58f05d3447e67109cfb75e042803b843ca4" }
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
arc-swap = "1.9.0"
|
||||
assert_cmd = "2"
|
||||
@@ -330,6 +356,7 @@ flate2 = "1.1.8"
|
||||
futures = { version = "0.3", default-features = false }
|
||||
gethostname = "1.1.0"
|
||||
gix = { version = "0.81.0", default-features = false, features = ["sha1"] }
|
||||
gix-url = "0.35.2"
|
||||
glob = "0.3"
|
||||
globset = "0.4"
|
||||
hmac = "0.12.1"
|
||||
@@ -356,7 +383,7 @@ libc = "0.2.182"
|
||||
# https://www.sqlite.org/wal.html#the_wal_reset_bug
|
||||
libsqlite3-sys = { version = "0.37", default-features = false }
|
||||
log = "0.4"
|
||||
lru = "0.16.3"
|
||||
lru = "0.18.2"
|
||||
maplit = "1.0.2"
|
||||
memchr = "2.7.6"
|
||||
mime_guess = "2.0.5"
|
||||
@@ -392,8 +419,9 @@ rcgen = { version = "0.14.7", default-features = false, features = [
|
||||
regex = "1.12.3"
|
||||
regex-lite = "0.1.8"
|
||||
reqwest = { version = "0.12", features = ["cookies"] }
|
||||
rmcp = { version = "=3.0.0", default-features = false }
|
||||
rmcp = { version = "=3.2.0", default-features = false }
|
||||
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
|
||||
rustix = { version = "1.1.4", features = ["net"] }
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
"aws_lc_rs",
|
||||
"std",
|
||||
@@ -448,6 +476,7 @@ tempfile = "3.23.0"
|
||||
test-log = "0.2.19"
|
||||
textwrap = "0.16.2"
|
||||
thiserror = "2.0.17"
|
||||
tikv-jemallocator = "=0.7.0"
|
||||
time = "0.3.47"
|
||||
tiny_http = "0.12"
|
||||
tokio = "1"
|
||||
@@ -470,6 +499,7 @@ tonic = { version = "0.14.3", default-features = false, features = ["channel", "
|
||||
tonic-prost = "0.14.3"
|
||||
tree-sitter = "0.25.10"
|
||||
tree-sitter-bash = "0.25"
|
||||
tree-sitter-powershell = "=0.26.4"
|
||||
ts-rs = "11"
|
||||
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
|
||||
uds_windows = "1.1.0"
|
||||
@@ -481,7 +511,7 @@ uuid = "1"
|
||||
v8 = "=150.4.0"
|
||||
vt100 = "0.16.2"
|
||||
walkdir = "2.5.0"
|
||||
webbrowser = "1.0"
|
||||
webbrowser = "1.2.2"
|
||||
which = "8"
|
||||
whoami = "1.6.1"
|
||||
wildmatch = "2.6.1"
|
||||
@@ -579,7 +609,7 @@ opt-level = 0
|
||||
[patch.crates-io]
|
||||
# Uncomment to debug local changes.
|
||||
# ratatui = { path = "../../ratatui" }
|
||||
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "f69a4a0499f2fdc7d5d222df32373ffffe9ba3f5" }
|
||||
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "45fecb9508105988f42fe6ff0441783ed3717f92" }
|
||||
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" }
|
||||
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
|
||||
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "agent-roles",
|
||||
crate_name = "codex_agent_roles",
|
||||
)
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-agent-roles"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_agent_roles"
|
||||
path = "src/lib.rs"
|
||||
test = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-config = { workspace = true }
|
||||
codex-file-system = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-path-uri = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
toml = { workspace = true, features = ["preserve_order"] }
|
||||
tracing = { workspace = true }
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AgentRoleConfig {
|
||||
/// Human-facing role documentation used in spawn tool guidance.
|
||||
/// Required for loaded user-defined roles after deprecated/new metadata precedence resolves.
|
||||
pub description: Option<String>,
|
||||
/// Path to a role-specific config layer.
|
||||
pub config_file: Option<PathBuf>,
|
||||
/// Candidate nicknames for agents spawned with this role.
|
||||
pub nickname_candidates: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawAgentRoleFileToml {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
nickname_candidates: Option<Vec<String>>,
|
||||
#[serde(flatten)]
|
||||
config: ConfigToml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedAgentRoleFile {
|
||||
pub role_name: String,
|
||||
pub description: Option<String>,
|
||||
pub nickname_candidates: Option<Vec<String>>,
|
||||
pub config: TomlValue,
|
||||
}
|
||||
|
||||
pub fn parse_agent_role_file_contents(
|
||||
contents: &str,
|
||||
role_file_label: &Path,
|
||||
config_base_dir: &Path,
|
||||
role_name_hint: Option<&str>,
|
||||
) -> std::io::Result<ResolvedAgentRoleFile> {
|
||||
let role_file_toml: TomlValue = toml::from_str(contents).map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"failed to parse agent role file at {}: {err}",
|
||||
role_file_label.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let _guard = AbsolutePathBufGuard::new(config_base_dir);
|
||||
let parsed: RawAgentRoleFileToml = role_file_toml.clone().try_into().map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"failed to deserialize agent role file at {}: {err}",
|
||||
role_file_label.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let description = normalize_agent_role_description(
|
||||
&format!("agent role file {}.description", role_file_label.display()),
|
||||
parsed.description.as_deref(),
|
||||
)?;
|
||||
validate_agent_role_file_developer_instructions(
|
||||
role_file_label,
|
||||
parsed.config.developer_instructions.as_deref(),
|
||||
role_name_hint.is_none(),
|
||||
)?;
|
||||
|
||||
let role_name = parsed
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| role_name_hint.map(ToOwned::to_owned))
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"agent role file at {} must define a non-empty `name`",
|
||||
role_file_label.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let nickname_candidates = normalize_agent_role_nickname_candidates(
|
||||
&format!(
|
||||
"agent role file {}.nickname_candidates",
|
||||
role_file_label.display()
|
||||
),
|
||||
parsed.nickname_candidates.as_deref(),
|
||||
)?;
|
||||
|
||||
let mut config = role_file_toml;
|
||||
let Some(config_table) = config.as_table_mut() else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"agent role file at {} must contain a TOML table",
|
||||
role_file_label.display()
|
||||
),
|
||||
));
|
||||
};
|
||||
config_table.remove("name");
|
||||
config_table.remove("description");
|
||||
config_table.remove("nickname_candidates");
|
||||
|
||||
Ok(ResolvedAgentRoleFile {
|
||||
role_name,
|
||||
description,
|
||||
nickname_candidates,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_agent_role_description(
|
||||
field_label: &str,
|
||||
description: Option<&str>,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
match description.map(str::trim) {
|
||||
Some("") => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{field_label} cannot be blank"),
|
||||
)),
|
||||
Some(description) => Ok(Some(description.to_string())),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_agent_role_file_developer_instructions(
|
||||
role_file_label: &Path,
|
||||
developer_instructions: Option<&str>,
|
||||
require_present: bool,
|
||||
) -> std::io::Result<()> {
|
||||
match developer_instructions.map(str::trim) {
|
||||
Some("") => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"agent role file at {}.developer_instructions cannot be blank",
|
||||
role_file_label.display()
|
||||
),
|
||||
)),
|
||||
Some(_) => Ok(()),
|
||||
None if require_present => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"agent role file at {} must define `developer_instructions`",
|
||||
role_file_label.display()
|
||||
),
|
||||
)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_agent_role_nickname_candidates(
|
||||
field_label: &str,
|
||||
nickname_candidates: Option<&[String]>,
|
||||
) -> std::io::Result<Option<Vec<String>>> {
|
||||
let Some(nickname_candidates) = nickname_candidates else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if nickname_candidates.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{field_label} must contain at least one name"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut normalized_candidates = Vec::with_capacity(nickname_candidates.len());
|
||||
let mut seen_candidates = BTreeSet::new();
|
||||
|
||||
for nickname in nickname_candidates {
|
||||
let normalized_nickname = nickname.trim();
|
||||
if normalized_nickname.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{field_label} cannot contain blank names"),
|
||||
));
|
||||
}
|
||||
|
||||
if !seen_candidates.insert(normalized_nickname.to_owned()) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{field_label} cannot contain duplicates"),
|
||||
));
|
||||
}
|
||||
|
||||
if !normalized_nickname
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_'))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"{field_label} may only contain ASCII letters, digits, spaces, hyphens, and underscores"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
normalized_candidates.push(normalized_nickname.to_owned());
|
||||
}
|
||||
|
||||
Ok(Some(normalized_candidates))
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
use codex_file_system::ExecutorFileSystem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::io;
|
||||
use std::io::ErrorKind;
|
||||
|
||||
pub(crate) async fn collect_agent_role_files(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
dir: &AbsolutePathBuf,
|
||||
) -> io::Result<Vec<AbsolutePathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
let mut dirs = vec![dir.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
let dir_uri = PathUri::from_abs_path(&dir);
|
||||
let entries = match fs.read_directory(&dir_uri, /*sandbox*/ None).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let path = dir.join(entry.file_name);
|
||||
if entry.is_directory {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
if entry.is_file
|
||||
&& path
|
||||
.as_path()
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "toml")
|
||||
{
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
mod agent_role_config;
|
||||
mod discovery;
|
||||
mod loader;
|
||||
|
||||
pub use agent_role_config::AgentRoleConfig;
|
||||
pub use agent_role_config::ResolvedAgentRoleFile;
|
||||
pub use agent_role_config::parse_agent_role_file_contents;
|
||||
pub use loader::load_agent_roles;
|
||||
+18
-233
@@ -1,21 +1,26 @@
|
||||
use super::AgentRoleConfig;
|
||||
use crate::AgentRoleConfig;
|
||||
use crate::ResolvedAgentRoleFile;
|
||||
use crate::agent_role_config::normalize_agent_role_description;
|
||||
use crate::agent_role_config::normalize_agent_role_nickname_candidates;
|
||||
use crate::discovery::collect_agent_role_files;
|
||||
use crate::parse_agent_role_file_contents;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::config_toml::AgentRoleToml;
|
||||
use codex_config::config_toml::AgentsToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_file_system::ExecutorFileSystem;
|
||||
use codex_file_system::GetMetadataOptions;
|
||||
use codex_file_system::ReadFileOptions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
pub(crate) async fn load_agent_roles(
|
||||
pub async fn load_agent_roles(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
cfg: &ConfigToml,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
@@ -211,113 +216,15 @@ async fn agent_role_config_from_toml(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawAgentRoleFileToml {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
nickname_candidates: Option<Vec<String>>,
|
||||
#[serde(flatten)]
|
||||
config: ConfigToml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ResolvedAgentRoleFile {
|
||||
pub(crate) role_name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) nickname_candidates: Option<Vec<String>>,
|
||||
pub(crate) config: TomlValue,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_agent_role_file_contents(
|
||||
contents: &str,
|
||||
role_file_label: &Path,
|
||||
config_base_dir: &Path,
|
||||
role_name_hint: Option<&str>,
|
||||
) -> std::io::Result<ResolvedAgentRoleFile> {
|
||||
let role_file_toml: TomlValue = toml::from_str(contents).map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"failed to parse agent role file at {}: {err}",
|
||||
role_file_label.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let _guard = AbsolutePathBufGuard::new(config_base_dir);
|
||||
let parsed: RawAgentRoleFileToml = role_file_toml.clone().try_into().map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"failed to deserialize agent role file at {}: {err}",
|
||||
role_file_label.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let description = normalize_agent_role_description(
|
||||
&format!("agent role file {}.description", role_file_label.display()),
|
||||
parsed.description.as_deref(),
|
||||
)?;
|
||||
validate_agent_role_file_developer_instructions(
|
||||
role_file_label,
|
||||
parsed.config.developer_instructions.as_deref(),
|
||||
role_name_hint.is_none(),
|
||||
)?;
|
||||
|
||||
let role_name = parsed
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| role_name_hint.map(ToOwned::to_owned))
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"agent role file at {} must define a non-empty `name`",
|
||||
role_file_label.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let nickname_candidates = normalize_agent_role_nickname_candidates(
|
||||
&format!(
|
||||
"agent role file {}.nickname_candidates",
|
||||
role_file_label.display()
|
||||
),
|
||||
parsed.nickname_candidates.as_deref(),
|
||||
)?;
|
||||
|
||||
let mut config = role_file_toml;
|
||||
let Some(config_table) = config.as_table_mut() else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"agent role file at {} must contain a TOML table",
|
||||
role_file_label.display()
|
||||
),
|
||||
));
|
||||
};
|
||||
config_table.remove("name");
|
||||
config_table.remove("description");
|
||||
config_table.remove("nickname_candidates");
|
||||
|
||||
Ok(ResolvedAgentRoleFile {
|
||||
role_name,
|
||||
description,
|
||||
nickname_candidates,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_resolved_agent_role_file(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
role_name_hint: Option<&str>,
|
||||
) -> std::io::Result<ResolvedAgentRoleFile> {
|
||||
let path_uri = PathUri::from_abs_path(path);
|
||||
let contents = fs.read_file_text(&path_uri, /*sandbox*/ None).await?;
|
||||
let contents = fs
|
||||
.read_file_text(&path_uri, ReadFileOptions::default(), /*sandbox*/ None)
|
||||
.await?;
|
||||
let config_base_dir = path.parent().unwrap_or_else(|| path.clone());
|
||||
parse_agent_role_file_contents(
|
||||
&contents,
|
||||
@@ -327,20 +234,6 @@ async fn read_resolved_agent_role_file(
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_agent_role_description(
|
||||
field_label: &str,
|
||||
description: Option<&str>,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
match description.map(str::trim) {
|
||||
Some("") => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{field_label} cannot be blank"),
|
||||
)),
|
||||
Some(description) => Ok(Some(description.to_string())),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_required_agent_role_description(
|
||||
role_name: &str,
|
||||
description: Option<&str>,
|
||||
@@ -355,31 +248,6 @@ fn validate_required_agent_role_description(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_agent_role_file_developer_instructions(
|
||||
role_file_label: &Path,
|
||||
developer_instructions: Option<&str>,
|
||||
require_present: bool,
|
||||
) -> std::io::Result<()> {
|
||||
match developer_instructions.map(str::trim) {
|
||||
Some("") => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"agent role file at {}.developer_instructions cannot be blank",
|
||||
role_file_label.display()
|
||||
),
|
||||
)),
|
||||
Some(_) => Ok(()),
|
||||
None if require_present => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"agent role file at {} must define `developer_instructions`",
|
||||
role_file_label.display()
|
||||
),
|
||||
)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_agent_role_config_file(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
role_name: &str,
|
||||
@@ -391,7 +259,11 @@ async fn validate_agent_role_config_file(
|
||||
|
||||
let config_file_uri = PathUri::from_abs_path(config_file);
|
||||
let metadata = fs
|
||||
.get_metadata(&config_file_uri, /*sandbox*/ None)
|
||||
.get_metadata(
|
||||
&config_file_uri,
|
||||
GetMetadataOptions::default(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(
|
||||
@@ -415,58 +287,6 @@ async fn validate_agent_role_config_file(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_agent_role_nickname_candidates(
|
||||
field_label: &str,
|
||||
nickname_candidates: Option<&[String]>,
|
||||
) -> std::io::Result<Option<Vec<String>>> {
|
||||
let Some(nickname_candidates) = nickname_candidates else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if nickname_candidates.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{field_label} must contain at least one name"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut normalized_candidates = Vec::with_capacity(nickname_candidates.len());
|
||||
let mut seen_candidates = BTreeSet::new();
|
||||
|
||||
for nickname in nickname_candidates {
|
||||
let normalized_nickname = nickname.trim();
|
||||
if normalized_nickname.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{field_label} cannot contain blank names"),
|
||||
));
|
||||
}
|
||||
|
||||
if !seen_candidates.insert(normalized_nickname.to_owned()) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{field_label} cannot contain duplicates"),
|
||||
));
|
||||
}
|
||||
|
||||
if !normalized_nickname
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_'))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"{field_label} may only contain ASCII letters, digits, spaces, hyphens, and underscores"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
normalized_candidates.push(normalized_nickname.to_owned());
|
||||
}
|
||||
|
||||
Ok(Some(normalized_candidates))
|
||||
}
|
||||
|
||||
async fn discover_agent_roles_in_dir(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
agents_dir: &AbsolutePathBuf,
|
||||
@@ -513,38 +333,3 @@ async fn discover_agent_roles_in_dir(
|
||||
|
||||
Ok(roles)
|
||||
}
|
||||
|
||||
async fn collect_agent_role_files(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
dir: &AbsolutePathBuf,
|
||||
) -> std::io::Result<Vec<AbsolutePathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
let mut dirs = vec![dir.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
let dir_uri = PathUri::from_abs_path(&dir);
|
||||
let entries = match fs.read_directory(&dir_uri, /*sandbox*/ None).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let path = dir.join(entry.file_name);
|
||||
if entry.is_directory {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
if entry.is_file
|
||||
&& path
|
||||
.as_path()
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "toml")
|
||||
{
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
+24
-93
@@ -1,17 +1,15 @@
|
||||
use crate::events::CodexAcceptedLineFingerprintsEventParams;
|
||||
use crate::events::CodexAcceptedLineFingerprintsEventRequest;
|
||||
use crate::events::TrackEventRequest;
|
||||
use crate::facts::AcceptedLineFingerprint;
|
||||
use codex_git_utils::canonicalize_git_remote_url;
|
||||
use codex_git_utils::get_git_remote_urls_assume_git_repo;
|
||||
use sha1::Digest;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AcceptedLineFingerprintSummary {
|
||||
pub accepted_added_lines: u64,
|
||||
pub accepted_deleted_lines: u64,
|
||||
pub line_fingerprints: Vec<AcceptedLineFingerprint>,
|
||||
pub(crate) struct AcceptedLineCounts {
|
||||
pub(crate) accepted_added_lines: u64,
|
||||
pub(crate) accepted_deleted_lines: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct AcceptedLineFingerprintEventInput {
|
||||
@@ -24,21 +22,15 @@ pub(crate) struct AcceptedLineFingerprintEventInput {
|
||||
pub(crate) repo_hash: Option<String>,
|
||||
pub(crate) accepted_added_lines: u64,
|
||||
pub(crate) accepted_deleted_lines: u64,
|
||||
pub(crate) line_fingerprints: Vec<AcceptedLineFingerprint>,
|
||||
}
|
||||
|
||||
pub fn accepted_line_fingerprints_from_unified_diff(
|
||||
unified_diff: &str,
|
||||
) -> AcceptedLineFingerprintSummary {
|
||||
let mut current_path: Option<String> = None;
|
||||
pub(crate) fn accepted_line_counts_from_unified_diff(unified_diff: &str) -> AcceptedLineCounts {
|
||||
let mut in_hunk = false;
|
||||
let mut accepted_added_lines = 0;
|
||||
let mut accepted_deleted_lines = 0;
|
||||
let mut line_fingerprints = Vec::new();
|
||||
|
||||
for line in unified_diff.lines() {
|
||||
if line.starts_with("diff --git ") {
|
||||
current_path = None;
|
||||
in_hunk = false;
|
||||
continue;
|
||||
}
|
||||
@@ -48,25 +40,12 @@ pub fn accepted_line_fingerprints_from_unified_diff(
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_hunk && let Some(path) = line.strip_prefix("+++ ") {
|
||||
current_path = normalize_diff_path(path);
|
||||
if !in_hunk && (line.starts_with("+++ ") || line.starts_with("--- ")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_hunk && line.starts_with("--- ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(added_line) = line.strip_prefix('+') {
|
||||
if line.starts_with('+') {
|
||||
accepted_added_lines += 1;
|
||||
if let Some(path) = current_path.as_deref()
|
||||
&& let Some(normalized_line) = normalize_effective_line(added_line)
|
||||
{
|
||||
line_fingerprints.push(AcceptedLineFingerprint {
|
||||
path_hash: fingerprint_hash("path", path),
|
||||
line_hash: fingerprint_hash("line", &normalized_line),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -75,10 +54,9 @@ pub fn accepted_line_fingerprints_from_unified_diff(
|
||||
}
|
||||
}
|
||||
|
||||
AcceptedLineFingerprintSummary {
|
||||
AcceptedLineCounts {
|
||||
accepted_added_lines,
|
||||
accepted_deleted_lines,
|
||||
line_fingerprints,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +82,6 @@ pub(crate) fn accepted_line_fingerprint_event_requests(
|
||||
repo_hash,
|
||||
accepted_added_lines,
|
||||
accepted_deleted_lines,
|
||||
line_fingerprints: _line_fingerprints,
|
||||
} = input;
|
||||
|
||||
vec![TrackEventRequest::AcceptedLineFingerprints(Box::new(
|
||||
@@ -120,9 +97,7 @@ pub(crate) fn accepted_line_fingerprint_event_requests(
|
||||
repo_hash,
|
||||
accepted_added_lines,
|
||||
accepted_deleted_lines,
|
||||
// Keep computing local fingerprints for parsing tests and future attribution,
|
||||
// but do not upload path/line hashes in the analytics event payload.
|
||||
line_fingerprints: Vec::new(),
|
||||
line_fingerprints: [],
|
||||
},
|
||||
},
|
||||
))]
|
||||
@@ -134,46 +109,18 @@ pub async fn accepted_line_repo_hash_for_cwd(cwd: &Path) -> Option<String> {
|
||||
.get("origin")
|
||||
.or_else(|| remotes.values().next())
|
||||
.map(|remote_url| {
|
||||
let canonical_remote_url =
|
||||
canonicalize_git_remote_url(remote_url).unwrap_or_else(|| remote_url.to_string());
|
||||
let canonical_remote_url = canonicalize_git_remote_url(remote_url.as_str())
|
||||
.unwrap_or_else(|| remote_url.to_string());
|
||||
fingerprint_hash("repo", &canonical_remote_url)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_diff_path(path: &str) -> Option<String> {
|
||||
let path = path.trim();
|
||||
if path == "/dev/null" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
path.strip_prefix("b/")
|
||||
.or_else(|| path.strip_prefix("a/"))
|
||||
.unwrap_or(path)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_effective_line(line: &str) -> Option<String> {
|
||||
let normalized = line.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.len() <= 3 {
|
||||
return None;
|
||||
}
|
||||
if !normalized
|
||||
.chars()
|
||||
.any(|ch| ch.is_alphanumeric() || ch == '_')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_counts_and_effective_added_fingerprints() {
|
||||
fn parses_accepted_line_counts() {
|
||||
let diff = "\
|
||||
diff --git a/src/lib.rs b/src/lib.rs
|
||||
index 1111111..2222222
|
||||
@@ -187,24 +134,12 @@ index 1111111..2222222
|
||||
context
|
||||
";
|
||||
|
||||
let summary = accepted_line_fingerprints_from_unified_diff(diff);
|
||||
|
||||
assert_eq!(
|
||||
summary,
|
||||
AcceptedLineFingerprintSummary {
|
||||
accepted_line_counts_from_unified_diff(diff),
|
||||
AcceptedLineCounts {
|
||||
accepted_added_lines: 3,
|
||||
accepted_deleted_lines: 1,
|
||||
line_fingerprints: vec![
|
||||
AcceptedLineFingerprint {
|
||||
path_hash: fingerprint_hash("path", "src/lib.rs"),
|
||||
line_hash: fingerprint_hash("line", "fn useful() {"),
|
||||
},
|
||||
AcceptedLineFingerprint {
|
||||
path_hash: fingerprint_hash("path", "src/lib.rs"),
|
||||
line_hash: fingerprint_hash("line", "return user.id;"),
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,11 +155,13 @@ index 0000000..1111111
|
||||
+print('hello')
|
||||
";
|
||||
|
||||
let summary = accepted_line_fingerprints_from_unified_diff(diff);
|
||||
|
||||
assert_eq!(summary.accepted_added_lines, 1);
|
||||
assert_eq!(summary.accepted_deleted_lines, 0);
|
||||
assert_eq!(summary.line_fingerprints.len(), 1);
|
||||
assert_eq!(
|
||||
accepted_line_counts_from_unified_diff(diff),
|
||||
AcceptedLineCounts {
|
||||
accepted_added_lines: 1,
|
||||
accepted_deleted_lines: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -239,18 +176,12 @@ index 1111111..2222222
|
||||
+++ new value
|
||||
";
|
||||
|
||||
let summary = accepted_line_fingerprints_from_unified_diff(diff);
|
||||
|
||||
assert_eq!(
|
||||
summary,
|
||||
AcceptedLineFingerprintSummary {
|
||||
accepted_line_counts_from_unified_diff(diff),
|
||||
AcceptedLineCounts {
|
||||
accepted_added_lines: 1,
|
||||
accepted_deleted_lines: 1,
|
||||
line_fingerprints: vec![AcceptedLineFingerprint {
|
||||
path_hash: fingerprint_hash("path", "src/lib.rs"),
|
||||
line_hash: fingerprint_hash("line", "++ new value"),
|
||||
}],
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1013
-101
File diff suppressed because it is too large
Load Diff
Vendored
+196
@@ -9,6 +9,8 @@ use crate::facts::AnalyticsJsonRpcError;
|
||||
use crate::facts::AppInvocation;
|
||||
use crate::facts::AppMentionedInput;
|
||||
use crate::facts::AppUsedInput;
|
||||
use crate::facts::ArtifactOperation;
|
||||
use crate::facts::ArtifactOperationInput;
|
||||
use crate::facts::CodexGoalEvent;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::ExternalAgentConfigImportCompletedInput;
|
||||
@@ -20,6 +22,7 @@ use crate::facts::PluginInstallFailedInput;
|
||||
use crate::facts::PluginInstallRequested;
|
||||
use crate::facts::PluginInstallRequestedInput;
|
||||
use crate::facts::PluginInstallSource;
|
||||
use crate::facts::PluginMeasurementsInput;
|
||||
use crate::facts::PluginState;
|
||||
use crate::facts::PluginStateChangedInput;
|
||||
use crate::facts::SkillInvocation;
|
||||
@@ -30,21 +33,41 @@ use crate::facts::TurnCodexErrorFact;
|
||||
use crate::facts::TurnProfileFact;
|
||||
use crate::facts::TurnResolvedConfigFact;
|
||||
use crate::facts::TurnTokenUsageFact;
|
||||
use crate::guardian_v2::GuardianV2Event;
|
||||
use crate::now_unix_millis;
|
||||
use crate::reducer::AnalyticsReducer;
|
||||
use crate::reducer::MAX_PLUGIN_MEASUREMENTS_PER_BATCH;
|
||||
use crate::reducer::tracked_tool_item_id;
|
||||
use crate::reducer::valid_plugin_measurement_identifier;
|
||||
use crate::reducer::valid_plugin_measurement_row;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ClientResponsePayload;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ServerResponse;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnError;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::item_event_to_server_notification;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::create_client;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::CollabAgentToolCallItem;
|
||||
use codex_protocol::items::CollabAgentToolCallStatus;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
@@ -253,6 +276,26 @@ impl AnalyticsEventsClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.queue.is_some()
|
||||
}
|
||||
|
||||
pub fn track_plugin_measurements(&self, mut input: PluginMeasurementsInput) {
|
||||
if input.rows.is_empty()
|
||||
|| input.rows.len() > MAX_PLUGIN_MEASUREMENTS_PER_BATCH
|
||||
|| !valid_plugin_measurement_identifier(&input.operation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
input.rows.retain(valid_plugin_measurement_row);
|
||||
if input.rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::PluginMeasurements(input),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn track_skill_invocations(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
@@ -269,6 +312,19 @@ impl AnalyticsEventsClient {
|
||||
)));
|
||||
}
|
||||
|
||||
pub fn track_artifact_operation(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
operation: ArtifactOperation,
|
||||
) {
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::ArtifactOperation(ArtifactOperationInput {
|
||||
tracking,
|
||||
operation,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn track_initialize(
|
||||
&self,
|
||||
connection_id: u64,
|
||||
@@ -291,12 +347,52 @@ impl AnalyticsEventsClient {
|
||||
));
|
||||
}
|
||||
|
||||
/// Logs Guardian turn and tool events that bypass the app-server listener.
|
||||
pub fn track_guardian_session_event(&self, thread_id: ThreadId, event: &Event) {
|
||||
if let Some(notification) = session_event_to_analytics_notification(thread_id, event) {
|
||||
self.track_notification(¬ification);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn track_collab_tool_call(
|
||||
&self,
|
||||
turn_id: String,
|
||||
mut item: CollabAgentToolCallItem,
|
||||
started_at_ms: i64,
|
||||
completed_at_ms: i64,
|
||||
) {
|
||||
let thread_id = item.sender_thread_id.to_string();
|
||||
let completed_item = TurnItem::CollabAgentToolCall(item.clone()).into();
|
||||
item.status = CollabAgentToolCallStatus::InProgress;
|
||||
|
||||
self.track_notification(&ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
item: TurnItem::CollabAgentToolCall(item).into(),
|
||||
thread_id: thread_id.clone(),
|
||||
turn_id: turn_id.clone(),
|
||||
started_at_ms,
|
||||
}));
|
||||
self.track_notification(&ServerNotification::ItemCompleted(
|
||||
ItemCompletedNotification {
|
||||
item: completed_item,
|
||||
thread_id,
|
||||
turn_id,
|
||||
completed_at_ms,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
pub fn track_code_mode_tool_call(&self, input: crate::facts::CodeModeToolCallFact) {
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::CodeModeToolCall(input),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn track_control_tool_call(&self, input: crate::facts::ControlToolCallFact) {
|
||||
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::ControlToolCall(
|
||||
input,
|
||||
)));
|
||||
}
|
||||
|
||||
pub fn track_guardian_review(
|
||||
&self,
|
||||
tracking: &GuardianReviewTrackContext,
|
||||
@@ -397,12 +493,24 @@ impl AnalyticsEventsClient {
|
||||
)));
|
||||
}
|
||||
|
||||
pub fn track_guardian_v2_event(&self, event: GuardianV2Event) {
|
||||
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianV2(
|
||||
Box::new(event),
|
||||
)));
|
||||
}
|
||||
|
||||
pub fn track_goal_event(&self, event: CodexGoalEvent) {
|
||||
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::Goal(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
|
||||
pub fn track_thread_hint_status(&self, event: crate::thread_hint::ThreadHintStatusEvent) {
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::ThreadHintStatus(Box::new(event)),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn track_image_preparation(&self, fact: ImagePreparationFact) {
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::ImagePreparation(Box::new(fact)),
|
||||
@@ -633,6 +741,94 @@ impl AnalyticsEventsClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_event_to_analytics_notification(
|
||||
thread_id: ThreadId,
|
||||
event: &Event,
|
||||
) -> Option<ServerNotification> {
|
||||
let notification = match &event.msg {
|
||||
EventMsg::ItemStarted(started) => item_event_to_server_notification(
|
||||
event.msg.clone(),
|
||||
&started.thread_id.to_string(),
|
||||
&started.turn_id,
|
||||
),
|
||||
EventMsg::ItemCompleted(completed) => item_event_to_server_notification(
|
||||
event.msg.clone(),
|
||||
&completed.thread_id.to_string(),
|
||||
&completed.turn_id,
|
||||
),
|
||||
EventMsg::TurnStarted(started) => {
|
||||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn: Turn {
|
||||
started_at: started.started_at,
|
||||
..analytics_turn(&started.turn_id, TurnStatus::InProgress)
|
||||
},
|
||||
})
|
||||
}
|
||||
EventMsg::TurnComplete(completed) => {
|
||||
let error = completed.error.as_ref().map(|error| TurnError {
|
||||
message: String::new(),
|
||||
codex_error_info: error.codex_error_info.clone().map(Into::into),
|
||||
additional_details: None,
|
||||
misalignment: None,
|
||||
});
|
||||
let status = if error.is_some() {
|
||||
TurnStatus::Failed
|
||||
} else {
|
||||
TurnStatus::Completed
|
||||
};
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn: Turn {
|
||||
error,
|
||||
started_at: completed.started_at,
|
||||
completed_at: completed.completed_at,
|
||||
duration_ms: completed.duration_ms,
|
||||
..analytics_turn(&completed.turn_id, status)
|
||||
},
|
||||
})
|
||||
}
|
||||
EventMsg::TurnAborted(aborted) => {
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn: Turn {
|
||||
started_at: aborted.started_at,
|
||||
completed_at: aborted.completed_at,
|
||||
duration_ms: aborted.duration_ms,
|
||||
..analytics_turn(
|
||||
aborted.turn_id.as_deref().unwrap_or(&event.id),
|
||||
TurnStatus::Interrupted,
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
// Legacy tool events accompany canonical items. Messages, reasoning, and review
|
||||
// content must not enter the analytics queue.
|
||||
_ => return None,
|
||||
};
|
||||
match ¬ification {
|
||||
ServerNotification::ItemStarted(ItemStartedNotification { item, .. })
|
||||
| ServerNotification::ItemCompleted(ItemCompletedNotification { item, .. }) => {
|
||||
tracked_tool_item_id(item)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(notification)
|
||||
}
|
||||
|
||||
fn analytics_turn(turn_id: &str, status: TurnStatus) -> Turn {
|
||||
Turn {
|
||||
id: turn_id.to_string(),
|
||||
items: Vec::new(),
|
||||
items_view: TurnItemsView::NotLoaded,
|
||||
status,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_track_events(
|
||||
auth_manager: &AuthManager,
|
||||
destination: &AnalyticsEventsDestination,
|
||||
|
||||
+146
-1
@@ -20,6 +20,10 @@ use crate::events::CodexMcpToolCallEventParams;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::CodexMcpToolCallEventRequest;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::CodexPluginMeasurementEventParams;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::CodexPluginMeasurementEventRequest;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::CodexPluginMetadata;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::CodexPluginUsedEventRequest;
|
||||
@@ -42,8 +46,20 @@ use crate::events::ThreadArchiveEventParams;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::ToolItemTerminalStatus;
|
||||
use crate::events::TrackEventRequest;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::codex_artifact_operation_event_request;
|
||||
use crate::facts::AnalyticsFact;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::facts::ArtifactOperation;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::facts::ArtifactOperationLifecycle;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::InvocationType;
|
||||
use crate::facts::PluginMeasurementRow;
|
||||
use crate::facts::PluginMeasurementsInput;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::facts::TrackEventsContext;
|
||||
use crate::reducer::MAX_PLUGIN_MEASUREMENTS_PER_BATCH;
|
||||
use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
|
||||
use codex_app_server_protocol::AskForApproval as AppServerAskForApproval;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
@@ -75,6 +91,7 @@ use codex_login::AuthManager;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
#[cfg(debug_assertions)]
|
||||
use std::fs;
|
||||
@@ -113,7 +130,7 @@ fn sample_accepted_line_fingerprint_event(thread_id: &str) -> TrackEventRequest
|
||||
repo_hash: None,
|
||||
accepted_added_lines: 1,
|
||||
accepted_deleted_lines: 0,
|
||||
line_fingerprints: Vec::new(),
|
||||
line_fingerprints: [],
|
||||
},
|
||||
},
|
||||
))
|
||||
@@ -138,6 +155,32 @@ fn sample_skill_track_event(thread_id: &str, plugin_id: Option<&str>) -> TrackEv
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn sample_artifact_operation_event(thread_id: &str) -> TrackEventRequest {
|
||||
TrackEventRequest::ArtifactOperation(codex_artifact_operation_event_request(
|
||||
TrackEventsContext {
|
||||
model_slug: "gpt-5.1-codex".to_string(),
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
product_client_id: "codex_desktop".to_string(),
|
||||
},
|
||||
ArtifactOperation {
|
||||
item_id: format!("item-{thread_id}"),
|
||||
lifecycle: ArtifactOperationLifecycle::Started,
|
||||
occurred_at_ms: 1,
|
||||
plugin_id: "presentations@openai-primary-runtime".to_string(),
|
||||
script_path: "skills/presentations/container_tools/mark_artifact_operation_started.mjs"
|
||||
.to_string(),
|
||||
skill: "presentations".to_string(),
|
||||
artifact_type: "presentation".to_string(),
|
||||
operation_kind: "create".to_string(),
|
||||
expected_output_count: 1,
|
||||
output_format: "pptx".to_string(),
|
||||
execution_backend: "unified_exec".to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn sample_regular_track_event(thread_id: &str) -> TrackEventRequest {
|
||||
sample_skill_track_event(thread_id, /*plugin_id*/ None)
|
||||
}
|
||||
@@ -151,6 +194,7 @@ fn sample_mcp_tool_call_event(thread_id: &str, plugin_id: Option<&str>) -> Track
|
||||
thread_id: thread_id.to_string(),
|
||||
session_id: format!("session-{thread_id}"),
|
||||
turn_id: "turn-1".to_string(),
|
||||
root_turn_id: None,
|
||||
item_id: format!("item-{thread_id}"),
|
||||
cell_id: None,
|
||||
parent_call_id: None,
|
||||
@@ -373,6 +417,23 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() {
|
||||
let auth_manager = codex_login::AuthManager::from_auth_for_testing(
|
||||
codex_login::CodexAuth::from_api_key("sk-test"),
|
||||
);
|
||||
let plugin_measurement = |thread_id: &str, plugin_id: &str| {
|
||||
TrackEventRequest::PluginMeasurement(CodexPluginMeasurementEventRequest {
|
||||
event_type: "codex_plugin_measurement_event",
|
||||
event_params: CodexPluginMeasurementEventParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "item-1".to_string(),
|
||||
originator: "codex_cli_rs".to_string(),
|
||||
plugin_id: plugin_id.to_string(),
|
||||
execution_id: "execution-1".to_string(),
|
||||
operation: "security_scan".to_string(),
|
||||
measurement_name: "findings".to_string(),
|
||||
number_value: 1.0,
|
||||
dimensions: None,
|
||||
},
|
||||
})
|
||||
};
|
||||
|
||||
send_track_events(
|
||||
&auth_manager,
|
||||
@@ -381,6 +442,7 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() {
|
||||
sample_regular_track_event("non-plugin-skill"),
|
||||
sample_mcp_tool_call_event("non-plugin-mcp", /*plugin_id*/ None),
|
||||
sample_plugin_used_track_event("non-plugin-used", /*plugin_id*/ None),
|
||||
plugin_measurement("non-plugin-measurement", /*plugin_id*/ ""),
|
||||
sample_accepted_line_fingerprint_event("other-event"),
|
||||
TrackEventRequest::ThreadArchive(ThreadArchiveEvent {
|
||||
event_type: "codex_thread_archive_event",
|
||||
@@ -388,11 +450,17 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() {
|
||||
thread_id: "non-plugin-thread-archive".to_string(),
|
||||
action: ThreadArchiveAction::Archived,
|
||||
occurred_at_ms: 1,
|
||||
app_server_client: None,
|
||||
runtime: None,
|
||||
thread_source: None,
|
||||
parent_thread_id: None,
|
||||
},
|
||||
}),
|
||||
sample_plugin_used_track_event("plugin-used", Some("sample@test")),
|
||||
sample_skill_track_event("plugin-skill", Some("sample@test")),
|
||||
sample_mcp_tool_call_event("plugin-mcp", Some("sample@test")),
|
||||
sample_artifact_operation_event("plugin-artifact"),
|
||||
plugin_measurement("plugin-measurement", "sample@test"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
@@ -442,6 +510,16 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() {
|
||||
"plugin_id": "sample@test",
|
||||
"thread_id": "plugin-mcp",
|
||||
}),
|
||||
serde_json::json!({
|
||||
"event_type": "codex_artifact_operation",
|
||||
"plugin_id": "presentations@openai-primary-runtime",
|
||||
"thread_id": "plugin-artifact",
|
||||
}),
|
||||
serde_json::json!({
|
||||
"event_type": "codex_plugin_measurement_event",
|
||||
"plugin_id": "sample@test",
|
||||
"thread_id": "plugin-measurement",
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -511,6 +589,8 @@ fn sample_thread_archive_request() -> ClientRequest {
|
||||
|
||||
fn sample_thread(thread_id: &str) -> Thread {
|
||||
Thread {
|
||||
originator: None,
|
||||
environments: None,
|
||||
id: thread_id.to_string(),
|
||||
extra: None,
|
||||
session_id: format!("session-{thread_id}"),
|
||||
@@ -520,8 +600,12 @@ fn sample_thread(thread_id: &str) -> Thread {
|
||||
ephemeral: false,
|
||||
section: None,
|
||||
section_entered_at: None,
|
||||
project_id: None,
|
||||
daybreak_enabled: None,
|
||||
history_mode: Default::default(),
|
||||
model_provider: "openai".to_string(),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
recency_at: Some(2),
|
||||
@@ -618,6 +702,67 @@ fn sample_turn_steer_response() -> ClientResponsePayload {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_plugin_measurements_rejects_unbounded_inputs_before_queueing() {
|
||||
let (client, mut receiver) = client_with_receiver();
|
||||
let measurements = |row_count| PluginMeasurementsInput {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "item-1".to_string(),
|
||||
originator: "codex_cli_rs".to_string(),
|
||||
plugin_id: "sample@openai-curated".to_string(),
|
||||
execution_id: "execution-1".to_string(),
|
||||
operation: "security_scan".to_string(),
|
||||
rows: vec![
|
||||
PluginMeasurementRow {
|
||||
measurement_name: "finding_count".to_string(),
|
||||
number_value: 1.0,
|
||||
dimensions: BTreeMap::new(),
|
||||
};
|
||||
row_count
|
||||
],
|
||||
};
|
||||
|
||||
client.track_plugin_measurements(measurements(MAX_PLUGIN_MEASUREMENTS_PER_BATCH + 1));
|
||||
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
||||
|
||||
let mut oversized_operation = measurements(1);
|
||||
oversized_operation.operation = "o".repeat(65);
|
||||
client.track_plugin_measurements(oversized_operation);
|
||||
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
||||
|
||||
let mut mixed_rows = measurements(4);
|
||||
mixed_rows.rows[0].measurement_name = "m".repeat(65);
|
||||
mixed_rows.rows[1]
|
||||
.dimensions
|
||||
.insert("d".repeat(65), "valid".to_string());
|
||||
mixed_rows.rows[2]
|
||||
.dimensions
|
||||
.insert("valid".to_string(), "v".repeat(65));
|
||||
client.track_plugin_measurements(mixed_rows);
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(AnalyticsEventsQueueMessage::Fact(fact))
|
||||
if matches!(
|
||||
fact.as_ref(),
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::PluginMeasurements(input))
|
||||
if input.rows.len() == 1
|
||||
&& input.rows[0].measurement_name == "finding_count"
|
||||
)
|
||||
));
|
||||
|
||||
client.track_plugin_measurements(measurements(MAX_PLUGIN_MEASUREMENTS_PER_BATCH));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(AnalyticsEventsQueueMessage::Fact(fact))
|
||||
if matches!(
|
||||
fact.as_ref(),
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::PluginMeasurements(input))
|
||||
if input.rows.len() == MAX_PLUGIN_MEASUREMENTS_PER_BATCH
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_request_only_enqueues_analytics_relevant_requests() {
|
||||
let (client, mut receiver) = client_with_receiver();
|
||||
|
||||
Vendored
+129
-5
@@ -1,7 +1,8 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::facts::AcceptedLineFingerprint;
|
||||
use crate::facts::AppInvocation;
|
||||
use crate::facts::ArtifactOperation;
|
||||
use crate::facts::ArtifactOperationLifecycle;
|
||||
use crate::facts::CodexCompactionEvent;
|
||||
use crate::facts::CodexErrKind;
|
||||
use crate::facts::CodexGoalEvent;
|
||||
@@ -24,6 +25,7 @@ use crate::facts::TurnStatus;
|
||||
use crate::facts::TurnSteerRejectionReason;
|
||||
use crate::facts::TurnSteerResult;
|
||||
use crate::facts::TurnSubmissionType;
|
||||
use crate::guardian_v2::GuardianV2EventRequest;
|
||||
use crate::now_unix_millis;
|
||||
use codex_app_server_protocol::CodexErrorInfo;
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
@@ -38,12 +40,15 @@ use codex_protocol::protocol::GuardianCommandSource;
|
||||
use codex_protocol::protocol::GuardianRiskLevel;
|
||||
use codex_protocol::protocol::GuardianUserAuthorization;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookExecutionMode;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -65,17 +70,22 @@ pub(crate) enum TrackEventRequest {
|
||||
ThreadInitialized(ThreadInitializedEvent),
|
||||
ThreadArchive(ThreadArchiveEvent),
|
||||
GuardianReview(Box<GuardianReviewEventRequest>),
|
||||
GuardianV2(Box<GuardianV2EventRequest>),
|
||||
AppMentioned(CodexAppMentionedEventRequest),
|
||||
AppUsed(CodexAppUsedEventRequest),
|
||||
HookRun(CodexHookRunEventRequest),
|
||||
Compaction(Box<CodexCompactionEventRequest>),
|
||||
Goal(Box<CodexGoalEventRequest>),
|
||||
ThreadHintStatus(Box<crate::thread_hint::ThreadHintStatusEventRequest>),
|
||||
TurnEvent(Box<CodexTurnEventRequest>),
|
||||
TurnSteer(CodexTurnSteerEventRequest),
|
||||
ArtifactOperation(CodexArtifactOperationEventRequest),
|
||||
CommandExecution(CodexCommandExecutionEventRequest),
|
||||
PluginMeasurement(CodexPluginMeasurementEventRequest),
|
||||
FileChange(CodexFileChangeEventRequest),
|
||||
McpToolCall(CodexMcpToolCallEventRequest),
|
||||
DynamicToolCall(CodexDynamicToolCallEventRequest),
|
||||
ControlToolCall(CodexControlToolCallEventRequest),
|
||||
CollabAgentToolCall(CodexCollabAgentToolCallEventRequest),
|
||||
WebSearch(CodexWebSearchEventRequest),
|
||||
ImageGeneration(CodexImageGenerationEventRequest),
|
||||
@@ -93,6 +103,59 @@ pub(crate) enum TrackEventRequest {
|
||||
ExternalAgentConfigImportFailure(CodexOnboardingExternalAgentImportFailureEventRequest),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexArtifactOperationEventParams {
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) item_id: String,
|
||||
pub(crate) lifecycle: ArtifactOperationLifecycle,
|
||||
pub(crate) occurred_at_ms: u64,
|
||||
pub(crate) product_client_id: String,
|
||||
pub(crate) runtime: CodexRuntimeMetadata,
|
||||
pub(crate) model_slug: String,
|
||||
pub(crate) plugin_id: String,
|
||||
pub(crate) script_path: String,
|
||||
pub(crate) skill: String,
|
||||
pub(crate) artifact_type: String,
|
||||
pub(crate) operation_kind: String,
|
||||
pub(crate) expected_output_count: u32,
|
||||
pub(crate) output_format: String,
|
||||
pub(crate) execution_backend: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexArtifactOperationEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: CodexArtifactOperationEventParams,
|
||||
}
|
||||
|
||||
pub(crate) fn codex_artifact_operation_event_request(
|
||||
tracking: TrackEventsContext,
|
||||
operation: ArtifactOperation,
|
||||
) -> CodexArtifactOperationEventRequest {
|
||||
CodexArtifactOperationEventRequest {
|
||||
event_type: "codex_artifact_operation",
|
||||
event_params: CodexArtifactOperationEventParams {
|
||||
thread_id: tracking.thread_id,
|
||||
turn_id: tracking.turn_id,
|
||||
item_id: operation.item_id,
|
||||
lifecycle: operation.lifecycle,
|
||||
occurred_at_ms: operation.occurred_at_ms,
|
||||
product_client_id: tracking.product_client_id,
|
||||
runtime: current_runtime_metadata(),
|
||||
model_slug: tracking.model_slug,
|
||||
plugin_id: operation.plugin_id,
|
||||
script_path: operation.script_path,
|
||||
skill: operation.skill,
|
||||
artifact_type: operation.artifact_type,
|
||||
operation_kind: operation.operation_kind,
|
||||
expected_output_count: operation.expected_output_count,
|
||||
output_format: operation.output_format,
|
||||
execution_backend: operation.execution_backend,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackEventRequest {
|
||||
pub(crate) fn should_send_in_isolated_request(&self) -> bool {
|
||||
matches!(self, Self::AcceptedLineFingerprints(_))
|
||||
@@ -103,6 +166,8 @@ impl TrackEventRequest {
|
||||
Self::PluginUsed(event) => event.event_params.plugin.plugin_id.is_some(),
|
||||
Self::SkillInvocation(event) => event.event_params.plugin_id.is_some(),
|
||||
Self::McpToolCall(event) => event.event_params.plugin_id.is_some(),
|
||||
Self::ArtifactOperation(event) => !event.event_params.plugin_id.is_empty(),
|
||||
Self::PluginMeasurement(event) => !event.event_params.plugin_id.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -119,7 +184,9 @@ pub(crate) struct CodexAcceptedLineFingerprintsEventParams {
|
||||
pub(crate) repo_hash: Option<String>,
|
||||
pub(crate) accepted_added_lines: u64,
|
||||
pub(crate) accepted_deleted_lines: u64,
|
||||
pub(crate) line_fingerprints: Vec<AcceptedLineFingerprint>,
|
||||
// Analytics ingestion and warehouse schemas require this field on the wire.
|
||||
// Keep it statically empty; line fingerprints are no longer generated.
|
||||
pub(crate) line_fingerprints: [(); 0],
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -200,6 +267,14 @@ pub(crate) struct ThreadArchiveEventParams {
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) action: ThreadArchiveAction,
|
||||
pub(crate) occurred_at_ms: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) app_server_client: Option<CodexAppServerClientMetadata>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) runtime: Option<CodexRuntimeMetadata>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) thread_source: Option<ThreadSource>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) parent_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -272,6 +347,9 @@ pub enum GuardianReviewedAction {
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
tty: bool,
|
||||
},
|
||||
WriteStdin {
|
||||
tty: bool,
|
||||
},
|
||||
Execve {
|
||||
source: GuardianCommandSource,
|
||||
program: String,
|
||||
@@ -557,6 +635,7 @@ pub(crate) struct CodexToolItemEventBase {
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) root_turn_id: Option<String>,
|
||||
/// App-server ThreadItem.id. For tool-originated items this generally
|
||||
/// corresponds to the originating core call_id.
|
||||
pub(crate) item_id: String,
|
||||
@@ -591,6 +670,7 @@ pub(crate) struct CodexToolItemEventBase {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ReviewSubjectKind {
|
||||
CommandExecution,
|
||||
WriteStdin,
|
||||
FileChange,
|
||||
McpToolCall,
|
||||
Permissions,
|
||||
@@ -693,6 +773,26 @@ pub(crate) struct CodexCommandExecutionEventRequest {
|
||||
pub(crate) event_params: CodexCommandExecutionEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexPluginMeasurementEventParams {
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) item_id: String,
|
||||
pub(crate) originator: String,
|
||||
pub(crate) plugin_id: String,
|
||||
pub(crate) execution_id: String,
|
||||
pub(crate) operation: String,
|
||||
pub(crate) measurement_name: String,
|
||||
pub(crate) number_value: f64,
|
||||
pub(crate) dimensions: Option<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexPluginMeasurementEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: CodexPluginMeasurementEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexFileChangeEventParams {
|
||||
#[serde(flatten)]
|
||||
@@ -745,6 +845,19 @@ pub(crate) struct CodexDynamicToolCallEventRequest {
|
||||
pub(crate) event_params: CodexDynamicToolCallEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexControlToolCallEventParams {
|
||||
#[serde(flatten)]
|
||||
pub(crate) base: CodexToolItemEventBase,
|
||||
pub(crate) success: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexControlToolCallEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: CodexControlToolCallEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexCollabAgentToolCallEventParams {
|
||||
#[serde(flatten)]
|
||||
@@ -786,6 +899,8 @@ pub(crate) struct CodexImageGenerationEventParams {
|
||||
pub(crate) base: CodexToolItemEventBase,
|
||||
pub(crate) revised_prompt_present: bool,
|
||||
pub(crate) saved_path_present: bool,
|
||||
pub(crate) transparent_background: Option<bool>,
|
||||
pub(crate) imagegen_request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -825,6 +940,8 @@ pub(crate) struct CodexHookRunMetadata {
|
||||
pub(crate) model_slug: Option<String>,
|
||||
pub(crate) hook_name: Option<String>,
|
||||
pub(crate) hook_source: Option<&'static str>,
|
||||
pub(crate) handler_type: Option<HookHandlerType>,
|
||||
pub(crate) execution_mode: Option<HookExecutionMode>,
|
||||
pub(crate) status: Option<HookRunStatus>,
|
||||
}
|
||||
|
||||
@@ -898,6 +1015,9 @@ pub(crate) struct CodexTurnEventParams {
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) root_turn_id: Option<String>,
|
||||
pub(crate) turn_trigger: Option<String>,
|
||||
pub(crate) codex_turn_source: Option<String>,
|
||||
// TODO(rhan-oai): Populate once queued/default submission type is plumbed from
|
||||
// the turn/start callsites instead of always being reported as None.
|
||||
pub(crate) submission_type: Option<TurnSubmissionType>,
|
||||
@@ -916,6 +1036,7 @@ pub(crate) struct CodexTurnEventParams {
|
||||
pub(crate) service_tier: String,
|
||||
pub(crate) approval_policy: String,
|
||||
pub(crate) approvals_reviewer: String,
|
||||
pub(crate) guardian_v2_enabled: bool,
|
||||
pub(crate) sandbox_network_access: bool,
|
||||
pub(crate) collaboration_mode: Option<&'static str>,
|
||||
pub(crate) personality: Option<String>,
|
||||
@@ -1279,6 +1400,8 @@ pub(crate) fn codex_hook_run_metadata(
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
hook_name: Some(analytics_hook_event_name(hook.event_name).to_owned()),
|
||||
hook_source: Some(analytics_hook_source(hook.hook_source)),
|
||||
handler_type: Some(hook.handler_type),
|
||||
execution_mode: Some(hook.execution_mode),
|
||||
status: Some(analytics_hook_status(hook.status)),
|
||||
}
|
||||
}
|
||||
@@ -1296,6 +1419,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::SubagentStart => "SubagentStart",
|
||||
HookEventName::SubagentStop => "SubagentStop",
|
||||
HookEventName::Stop => "Stop",
|
||||
HookEventName::Interrupt => "Interrupt",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1333,15 +1457,15 @@ pub(crate) fn subagent_thread_started_event_request(
|
||||
session_id: input.session_id,
|
||||
app_server_client: CodexAppServerClientMetadata {
|
||||
product_client_id: input.product_client_id,
|
||||
client_name: Some(input.client_name),
|
||||
client_version: Some(input.client_version),
|
||||
client_name: input.client_name,
|
||||
client_version: input.client_version,
|
||||
rpc_transport: AppServerRpcTransport::InProcess,
|
||||
experimental_api_enabled: None,
|
||||
},
|
||||
runtime: current_runtime_metadata(),
|
||||
model: input.model,
|
||||
ephemeral: input.ephemeral,
|
||||
thread_source: Some(ThreadSource::Subagent),
|
||||
thread_source: input.thread_source,
|
||||
initialization_mode: ThreadInitializationMode::New,
|
||||
subagent_source: Some(subagent_source_name(&input.subagent_source)),
|
||||
parent_thread_id: input.parent_thread_id,
|
||||
|
||||
Vendored
+112
-10
@@ -1,6 +1,7 @@
|
||||
use crate::events::AppServerRpcTransport;
|
||||
use crate::events::CodexRuntimeMetadata;
|
||||
use crate::events::GuardianReviewEventParams;
|
||||
use crate::guardian_v2::GuardianV2Event;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ClientResponsePayload;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
@@ -21,21 +22,20 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookExecutionMode;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AcceptedLineFingerprint {
|
||||
pub path_hash: String,
|
||||
pub line_hash: String,
|
||||
}
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TrackEventsContext {
|
||||
@@ -45,7 +45,28 @@ pub struct TrackEventsContext {
|
||||
pub product_client_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ArtifactOperationLifecycle {
|
||||
Started,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactOperation {
|
||||
pub item_id: String,
|
||||
pub lifecycle: ArtifactOperationLifecycle,
|
||||
pub occurred_at_ms: u64,
|
||||
pub plugin_id: String,
|
||||
pub script_path: String,
|
||||
pub skill: String,
|
||||
pub artifact_type: String,
|
||||
pub operation_kind: String,
|
||||
pub expected_output_count: u32,
|
||||
pub output_format: String,
|
||||
pub execution_backend: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum CodeModeToolCallFact {
|
||||
CellStarted {
|
||||
thread_id: String,
|
||||
@@ -73,6 +94,7 @@ pub enum CodeModeToolCallFact {
|
||||
Completed {
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
turn_metadata: Arc<dyn TurnAnalyticsMetadata>,
|
||||
call_id: String,
|
||||
cell_id: Option<String>,
|
||||
tool_name: String,
|
||||
@@ -89,6 +111,27 @@ pub enum CodeModeToolCallStatus {
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ControlToolCallFact {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub turn_metadata: Arc<dyn TurnAnalyticsMetadata>,
|
||||
pub call_id: String,
|
||||
pub cell_id: Option<String>,
|
||||
pub tool_name: String,
|
||||
pub started_at_ms: u64,
|
||||
pub completed_at_ms: u64,
|
||||
pub status: ControlToolCallStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ControlToolCallStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
Rejected,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
pub fn build_track_events_context(
|
||||
model_slug: String,
|
||||
thread_id: String,
|
||||
@@ -143,6 +186,7 @@ pub enum TurnSubmissionType {
|
||||
pub struct TurnResolvedConfigFact {
|
||||
pub turn_id: String,
|
||||
pub thread_id: String,
|
||||
pub turn_metadata: Arc<dyn TurnAnalyticsMetadata>,
|
||||
pub num_input_images: usize,
|
||||
pub submission_type: Option<TurnSubmissionType>,
|
||||
pub ephemeral: bool,
|
||||
@@ -156,6 +200,7 @@ pub struct TurnResolvedConfigFact {
|
||||
pub service_tier: Option<ServiceTier>,
|
||||
pub approval_policy: AskForApproval,
|
||||
pub approvals_reviewer: ApprovalsReviewer,
|
||||
pub guardian_v2_enabled: bool,
|
||||
pub sandbox_network_access: bool,
|
||||
pub collaboration_mode: ModeKind,
|
||||
pub personality: Option<Personality>,
|
||||
@@ -163,6 +208,19 @@ pub struct TurnResolvedConfigFact {
|
||||
pub is_first_turn: bool,
|
||||
}
|
||||
|
||||
/// A live, read-only view of a turn's analytics metadata.
|
||||
///
|
||||
/// Implementations must return `None` for unknown or ambiguous roots. The reducer
|
||||
/// reads this when constructing each event because steering can invalidate a root
|
||||
/// after the turn's configuration has been resolved.
|
||||
pub trait TurnAnalyticsMetadata: Send + Sync {
|
||||
fn root_turn_id(&self) -> Option<String>;
|
||||
/// The caller-provided trigger recorded when this turn started.
|
||||
fn turn_trigger(&self) -> Option<String>;
|
||||
/// The effective Responses source at event emission, including accepted steers.
|
||||
fn codex_turn_source(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadInitializationMode {
|
||||
@@ -307,13 +365,25 @@ impl From<InputError> for TurnSteerRejectionReason {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkillInvocation {
|
||||
pub skill_name: String,
|
||||
pub skill_scope: SkillScope,
|
||||
pub skill_path: PathBuf,
|
||||
pub location: SkillInvocationLocation,
|
||||
pub plugin_id: Option<String>,
|
||||
pub remote_plugin_id: Option<String>,
|
||||
pub invocation_type: InvocationType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SkillInvocationLocation {
|
||||
Host {
|
||||
path: PathBuf,
|
||||
scope: SkillScope,
|
||||
},
|
||||
Resource {
|
||||
id: String,
|
||||
skill_id: Option<String>,
|
||||
scope: Option<SkillScope>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InvocationType {
|
||||
@@ -334,10 +404,11 @@ pub struct SubAgentThreadStartedInput {
|
||||
pub parent_thread_id: Option<String>,
|
||||
pub forked_from_thread_id: Option<String>,
|
||||
pub product_client_id: String,
|
||||
pub client_name: String,
|
||||
pub client_version: String,
|
||||
pub client_name: Option<String>,
|
||||
pub client_version: Option<String>,
|
||||
pub model: String,
|
||||
pub ephemeral: bool,
|
||||
pub thread_source: Option<ThreadSource>,
|
||||
pub subagent_source: SubAgentSource,
|
||||
pub created_at: u64,
|
||||
}
|
||||
@@ -489,11 +560,15 @@ pub(crate) enum AnalyticsFact {
|
||||
}
|
||||
|
||||
pub(crate) enum CustomAnalyticsFact {
|
||||
ArtifactOperation(ArtifactOperationInput),
|
||||
CodeModeToolCall(CodeModeToolCallFact),
|
||||
ControlToolCall(ControlToolCallFact),
|
||||
SubAgentThreadStarted(SubAgentThreadStartedInput),
|
||||
Compaction(Box<CodexCompactionEvent>),
|
||||
Goal(Box<CodexGoalEvent>),
|
||||
ThreadHintStatus(Box<crate::thread_hint::ThreadHintStatusEvent>),
|
||||
GuardianReview(Box<GuardianReviewEventParams>),
|
||||
GuardianV2(Box<GuardianV2Event>),
|
||||
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
|
||||
TurnTokenUsage(Box<TurnTokenUsageFact>),
|
||||
TurnProfile(Box<TurnProfileFact>),
|
||||
@@ -507,10 +582,35 @@ pub(crate) enum CustomAnalyticsFact {
|
||||
PluginInstallRequested(PluginInstallRequestedInput),
|
||||
PluginStateChanged(PluginStateChangedInput),
|
||||
PluginInstallFailed(PluginInstallFailedInput),
|
||||
PluginMeasurements(PluginMeasurementsInput),
|
||||
ExternalAgentConfigImportCompleted(ExternalAgentConfigImportCompletedInput),
|
||||
ExternalAgentConfigImportFailure(ExternalAgentConfigImportFailureInput),
|
||||
}
|
||||
|
||||
pub(crate) struct ArtifactOperationInput {
|
||||
pub tracking: TrackEventsContext,
|
||||
pub operation: ArtifactOperation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PluginMeasurementRow {
|
||||
pub measurement_name: String,
|
||||
pub number_value: f64,
|
||||
pub dimensions: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PluginMeasurementsInput {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub originator: String,
|
||||
pub plugin_id: String,
|
||||
pub execution_id: String,
|
||||
pub operation: String,
|
||||
pub rows: Vec<PluginMeasurementRow>,
|
||||
}
|
||||
|
||||
pub(crate) struct SkillInvokedInput {
|
||||
pub tracking: TrackEventsContext,
|
||||
pub invocations: Vec<SkillInvocation>,
|
||||
@@ -534,6 +634,8 @@ pub(crate) struct HookRunInput {
|
||||
pub struct HookRunFact {
|
||||
pub event_name: HookEventName,
|
||||
pub hook_source: HookSource,
|
||||
pub handler_type: HookHandlerType,
|
||||
pub execution_mode: HookExecutionMode,
|
||||
pub status: HookRunStatus,
|
||||
}
|
||||
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
//! Guardian V2 classifier and fast-decision facts, enriched by the existing reducer.
|
||||
//! Payloads contain attribution and bounded outcomes, never prompts or tool arguments.
|
||||
|
||||
use crate::events::CodexAppServerClientMetadata;
|
||||
use crate::events::CodexRuntimeMetadata;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GuardianV2Event {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub occurred_at_ms: u64,
|
||||
#[serde(flatten)]
|
||||
pub kind: GuardianV2EventKind,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum GuardianV2EventKind {
|
||||
Classification {
|
||||
outcome: &'static str,
|
||||
risk_level: Option<&'static str>,
|
||||
duration_ms: u64,
|
||||
},
|
||||
FastDecision {
|
||||
decision: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct GuardianV2EventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: GuardianV2EventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct GuardianV2EventParams {
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
||||
pub(crate) runtime: CodexRuntimeMetadata,
|
||||
pub(crate) thread_source: Option<ThreadSource>,
|
||||
pub(crate) subagent_source: Option<String>,
|
||||
pub(crate) parent_thread_id: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub(crate) guardian_v2: GuardianV2Event,
|
||||
}
|
||||
Vendored
+14
-2
@@ -4,12 +4,13 @@ mod analytics_capture;
|
||||
mod client;
|
||||
mod events;
|
||||
mod facts;
|
||||
mod guardian_v2;
|
||||
mod reducer;
|
||||
mod thread_hint;
|
||||
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
pub use accepted_lines::accepted_line_fingerprints_from_unified_diff;
|
||||
pub use accepted_lines::fingerprint_hash;
|
||||
pub use client::AnalyticsEventsClient;
|
||||
pub use events::AppServerRpcTransport;
|
||||
@@ -23,9 +24,10 @@ pub use events::GuardianReviewSessionKind;
|
||||
pub use events::GuardianReviewTerminalStatus;
|
||||
pub use events::GuardianReviewTrackContext;
|
||||
pub use events::GuardianReviewedAction;
|
||||
pub use facts::AcceptedLineFingerprint;
|
||||
pub use facts::AnalyticsJsonRpcError;
|
||||
pub use facts::AppInvocation;
|
||||
pub use facts::ArtifactOperation;
|
||||
pub use facts::ArtifactOperationLifecycle;
|
||||
pub use facts::CodeModeToolCallFact;
|
||||
pub use facts::CodeModeToolCallStatus;
|
||||
pub use facts::CodexCompactionEvent;
|
||||
@@ -38,6 +40,8 @@ pub use facts::CompactionReason;
|
||||
pub use facts::CompactionStatus;
|
||||
pub use facts::CompactionStrategy;
|
||||
pub use facts::CompactionTrigger;
|
||||
pub use facts::ControlToolCallFact;
|
||||
pub use facts::ControlToolCallStatus;
|
||||
pub use facts::ExternalAgentConfigImportCompletedInput;
|
||||
pub use facts::ExternalAgentConfigImportFailureInput;
|
||||
pub use facts::GoalEventKind;
|
||||
@@ -51,10 +55,14 @@ pub use facts::PluginInstallRequestSource;
|
||||
pub use facts::PluginInstallRequested;
|
||||
pub use facts::PluginInstallRequestedPlugin;
|
||||
pub use facts::PluginInstallSource;
|
||||
pub use facts::PluginMeasurementRow;
|
||||
pub use facts::PluginMeasurementsInput;
|
||||
pub use facts::SkillInvocation;
|
||||
pub use facts::SkillInvocationLocation;
|
||||
pub use facts::SubAgentThreadStartedInput;
|
||||
pub use facts::ThreadInitializationMode;
|
||||
pub use facts::TrackEventsContext;
|
||||
pub use facts::TurnAnalyticsMetadata;
|
||||
pub use facts::TurnCodexErrorFact;
|
||||
pub use facts::TurnProfile;
|
||||
pub use facts::TurnProfileFact;
|
||||
@@ -65,6 +73,10 @@ pub use facts::TurnSteerRequestError;
|
||||
pub use facts::TurnSteerResult;
|
||||
pub use facts::TurnTokenUsageFact;
|
||||
pub use facts::build_track_events_context;
|
||||
pub use guardian_v2::GuardianV2Event;
|
||||
pub use guardian_v2::GuardianV2EventKind;
|
||||
pub use thread_hint::ThreadHintStatus;
|
||||
pub use thread_hint::ThreadHintStatusEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
mod analytics_client_tests;
|
||||
|
||||
+464
-64
@@ -1,6 +1,6 @@
|
||||
use crate::accepted_lines::AcceptedLineFingerprintEventInput;
|
||||
use crate::accepted_lines::accepted_line_counts_from_unified_diff;
|
||||
use crate::accepted_lines::accepted_line_fingerprint_event_requests;
|
||||
use crate::accepted_lines::accepted_line_fingerprints_from_unified_diff;
|
||||
use crate::accepted_lines::accepted_line_repo_hash_for_cwd;
|
||||
use crate::events::AppServerRpcTransport;
|
||||
use crate::events::CodexAppMentionedEventRequest;
|
||||
@@ -11,6 +11,8 @@ use crate::events::CodexCollabAgentToolCallEventRequest;
|
||||
use crate::events::CodexCommandExecutionEventParams;
|
||||
use crate::events::CodexCommandExecutionEventRequest;
|
||||
use crate::events::CodexCompactionEventRequest;
|
||||
use crate::events::CodexControlToolCallEventParams;
|
||||
use crate::events::CodexControlToolCallEventRequest;
|
||||
use crate::events::CodexDynamicToolCallEventParams;
|
||||
use crate::events::CodexDynamicToolCallEventRequest;
|
||||
use crate::events::CodexFileChangeEventParams;
|
||||
@@ -29,6 +31,8 @@ use crate::events::CodexPluginEventRequest;
|
||||
use crate::events::CodexPluginInstallFailedEventRequest;
|
||||
use crate::events::CodexPluginInstallFailedMetadata;
|
||||
use crate::events::CodexPluginInstallRequestedEventRequest;
|
||||
use crate::events::CodexPluginMeasurementEventParams;
|
||||
use crate::events::CodexPluginMeasurementEventRequest;
|
||||
use crate::events::CodexPluginUsedEventRequest;
|
||||
use crate::events::CodexReviewEventParams;
|
||||
use crate::events::CodexReviewEventRequest;
|
||||
@@ -61,6 +65,7 @@ use crate::events::ToolItemTerminalStatus;
|
||||
use crate::events::TrackEventRequest;
|
||||
use crate::events::WebSearchActionKind;
|
||||
use crate::events::codex_app_metadata;
|
||||
use crate::events::codex_artifact_operation_event_request;
|
||||
use crate::events::codex_compaction_event_params;
|
||||
use crate::events::codex_goal_event_params;
|
||||
use crate::events::codex_hook_run_metadata;
|
||||
@@ -74,21 +79,28 @@ use crate::facts::AnalyticsFact;
|
||||
use crate::facts::AnalyticsJsonRpcError;
|
||||
use crate::facts::AppMentionedInput;
|
||||
use crate::facts::AppUsedInput;
|
||||
use crate::facts::ArtifactOperationInput;
|
||||
use crate::facts::CodeModeToolCallFact;
|
||||
use crate::facts::CodeModeToolCallStatus;
|
||||
use crate::facts::CodexCompactionEvent;
|
||||
use crate::facts::CodexGoalEvent;
|
||||
use crate::facts::ControlToolCallFact;
|
||||
use crate::facts::ControlToolCallStatus;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::ExternalAgentConfigImportCompletedInput;
|
||||
use crate::facts::ExternalAgentConfigImportFailureInput;
|
||||
use crate::facts::HookRunInput;
|
||||
use crate::facts::ImagePreparationFact;
|
||||
use crate::facts::ImagePreparationMetadata;
|
||||
use crate::facts::InvocationType;
|
||||
use crate::facts::PluginInstallFailedInput;
|
||||
use crate::facts::PluginInstallRequestedInput;
|
||||
use crate::facts::PluginMeasurementRow;
|
||||
use crate::facts::PluginMeasurementsInput;
|
||||
use crate::facts::PluginState;
|
||||
use crate::facts::PluginStateChangedInput;
|
||||
use crate::facts::PluginUsedInput;
|
||||
use crate::facts::SkillInvocationLocation;
|
||||
use crate::facts::SkillInvokedInput;
|
||||
use crate::facts::SubAgentThreadStartedInput;
|
||||
use crate::facts::ThreadInitializationMode;
|
||||
@@ -101,6 +113,9 @@ use crate::facts::TurnStatus;
|
||||
use crate::facts::TurnSteerRejectionReason;
|
||||
use crate::facts::TurnSteerResult;
|
||||
use crate::facts::TurnTokenUsageFact;
|
||||
use crate::guardian_v2::GuardianV2EventKind;
|
||||
use crate::guardian_v2::GuardianV2EventParams;
|
||||
use crate::guardian_v2::GuardianV2EventRequest;
|
||||
use crate::now_unix_millis;
|
||||
use crate::now_unix_seconds;
|
||||
use crate::option_i64_to_u64;
|
||||
@@ -114,6 +129,7 @@ use codex_app_server_protocol::CollabAgentTool;
|
||||
use codex_app_server_protocol::CollabAgentToolCallStatus;
|
||||
use codex_app_server_protocol::CommandAction;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalKind;
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
|
||||
@@ -131,10 +147,12 @@ use codex_app_server_protocol::RequestPermissionProfile;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ServerResponse;
|
||||
use codex_app_server_protocol::SubAgentActivityKind;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::TurnSteerResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_app_server_protocol::WebSearchAction;
|
||||
use codex_git_utils::SanitizedGitUrl;
|
||||
use codex_git_utils::collect_git_info;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_login::default_client::originator;
|
||||
@@ -151,11 +169,34 @@ use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionG
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse;
|
||||
use sha1::Digest;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
const MAX_TOOL_RESPONSE_ENTRIES: usize = 256;
|
||||
|
||||
pub(crate) const MAX_PLUGIN_MEASUREMENTS_PER_BATCH: usize = 100;
|
||||
const MAX_PLUGIN_MEASUREMENT_DIMENSIONS: usize = 8;
|
||||
const MAX_PLUGIN_MEASUREMENT_IDENTIFIER_BYTES: usize = 64;
|
||||
|
||||
pub(crate) fn valid_plugin_measurement_identifier(value: &str) -> bool {
|
||||
let mut characters = value.chars();
|
||||
matches!(characters.next(), Some('a'..='z'))
|
||||
&& value.len() <= MAX_PLUGIN_MEASUREMENT_IDENTIFIER_BYTES
|
||||
&& characters.all(|character| {
|
||||
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn valid_plugin_measurement_row(row: &PluginMeasurementRow) -> bool {
|
||||
row.number_value.is_finite()
|
||||
&& valid_plugin_measurement_identifier(&row.measurement_name)
|
||||
&& row.dimensions.len() <= MAX_PLUGIN_MEASUREMENT_DIMENSIONS
|
||||
&& row.dimensions.iter().all(|(name, value)| {
|
||||
valid_plugin_measurement_identifier(name) && valid_plugin_measurement_identifier(value)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct AnalyticsReducer {
|
||||
requests: HashMap<(u64, RequestId), RequestState>,
|
||||
@@ -392,6 +433,8 @@ struct TurnState {
|
||||
latest_diff: Option<String>,
|
||||
steer_count: usize,
|
||||
tool_counts: TurnToolCounts,
|
||||
resource_skill_invocations: HashSet<String>,
|
||||
turn_event_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Hash, Eq, PartialEq)]
|
||||
@@ -427,6 +470,7 @@ struct TurnToolCounts {
|
||||
mcp_tool_call: usize,
|
||||
dynamic_tool_call: usize,
|
||||
subagent_tool_call: usize,
|
||||
subagent_tool_call_ids: HashSet<String>,
|
||||
web_search: usize,
|
||||
image_generation: usize,
|
||||
}
|
||||
@@ -438,7 +482,15 @@ impl TurnToolCounts {
|
||||
ThreadItem::FileChange { .. } => self.file_change += 1,
|
||||
ThreadItem::McpToolCall { .. } => self.mcp_tool_call += 1,
|
||||
ThreadItem::DynamicToolCall { .. } => self.dynamic_tool_call += 1,
|
||||
ThreadItem::CollabAgentToolCall { .. } | ThreadItem::SubAgentActivity { .. } => {
|
||||
ThreadItem::SubAgentActivity {
|
||||
kind: SubAgentActivityKind::Completed,
|
||||
..
|
||||
} => return,
|
||||
ThreadItem::CollabAgentToolCall { id, .. }
|
||||
| ThreadItem::SubAgentActivity { id, .. } => {
|
||||
if !self.subagent_tool_call_ids.insert(id.clone()) {
|
||||
return;
|
||||
}
|
||||
self.subagent_tool_call += 1;
|
||||
}
|
||||
ThreadItem::WebSearch(_) => self.web_search += 1,
|
||||
@@ -446,6 +498,7 @@ impl TurnToolCounts {
|
||||
ThreadItem::UserMessage { .. }
|
||||
| ThreadItem::HookPrompt { .. }
|
||||
| ThreadItem::AgentMessage { .. }
|
||||
| ThreadItem::FunctionCallOutput { .. }
|
||||
| ThreadItem::Plan { .. }
|
||||
| ThreadItem::Reasoning { .. }
|
||||
| ThreadItem::ImageView { .. }
|
||||
@@ -550,9 +603,15 @@ impl AnalyticsReducer {
|
||||
self.ingest_server_request_aborted(completed_at_ms, request_id, out);
|
||||
}
|
||||
AnalyticsFact::Custom(input) => match input {
|
||||
CustomAnalyticsFact::ArtifactOperation(input) => {
|
||||
self.ingest_artifact_operation(input, out);
|
||||
}
|
||||
CustomAnalyticsFact::CodeModeToolCall(input) => {
|
||||
self.ingest_code_mode_tool_call(input, out);
|
||||
}
|
||||
CustomAnalyticsFact::ControlToolCall(input) => {
|
||||
self.ingest_control_tool_call(input, out);
|
||||
}
|
||||
CustomAnalyticsFact::SubAgentThreadStarted(input) => {
|
||||
self.ingest_subagent_thread_started(input, out);
|
||||
}
|
||||
@@ -562,6 +621,68 @@ impl AnalyticsReducer {
|
||||
CustomAnalyticsFact::Goal(input) => {
|
||||
self.ingest_goal(*input, out);
|
||||
}
|
||||
CustomAnalyticsFact::ThreadHintStatus(input) => {
|
||||
if let Some((connection, thread, metadata)) =
|
||||
self.thread_context_or_warn(AnalyticsDropSite {
|
||||
event_name: "codex_thread_hint_status",
|
||||
thread_id: &input.thread_id,
|
||||
turn_id: None,
|
||||
review_id: None,
|
||||
item_id: None,
|
||||
})
|
||||
{
|
||||
out.push(TrackEventRequest::ThreadHintStatus(Box::new(
|
||||
crate::thread_hint::ThreadHintStatusEventRequest {
|
||||
event_type: "codex_thread_hint_status",
|
||||
event_params: crate::thread_hint::ThreadHintStatusEventParams {
|
||||
thread_id: input.thread_id,
|
||||
session_id: metadata.session_id.clone(),
|
||||
app_server_client: thread.app_server_client(connection),
|
||||
runtime: connection.runtime.clone(),
|
||||
thread_source: metadata.thread_source.clone(),
|
||||
subagent_source: metadata.subagent_source.clone(),
|
||||
parent_thread_id: metadata.parent_thread_id.clone(),
|
||||
status: input.status,
|
||||
occurred_at_ms: input.occurred_at_ms,
|
||||
},
|
||||
},
|
||||
)));
|
||||
}
|
||||
}
|
||||
CustomAnalyticsFact::GuardianV2(input) => {
|
||||
let event_type = match &input.kind {
|
||||
GuardianV2EventKind::Classification { .. } => {
|
||||
"codex_guardian_v2_classification"
|
||||
}
|
||||
GuardianV2EventKind::FastDecision { .. } => {
|
||||
"codex_guardian_v2_fast_decision"
|
||||
}
|
||||
};
|
||||
if let Some((connection, thread, metadata)) =
|
||||
self.thread_context_or_warn(AnalyticsDropSite {
|
||||
event_name: event_type,
|
||||
thread_id: &input.thread_id,
|
||||
turn_id: Some(&input.turn_id),
|
||||
review_id: None,
|
||||
item_id: input.item_id.as_deref(),
|
||||
})
|
||||
{
|
||||
out.push(TrackEventRequest::GuardianV2(Box::new(
|
||||
GuardianV2EventRequest {
|
||||
event_type,
|
||||
event_params: GuardianV2EventParams {
|
||||
session_id: metadata.session_id.clone(),
|
||||
app_server_client: thread.app_server_client(connection),
|
||||
runtime: connection.runtime.clone(),
|
||||
thread_source: metadata.thread_source.clone(),
|
||||
subagent_source: metadata.subagent_source.clone(),
|
||||
parent_thread_id: metadata.parent_thread_id.clone(),
|
||||
guardian_v2: *input,
|
||||
},
|
||||
},
|
||||
)));
|
||||
}
|
||||
}
|
||||
CustomAnalyticsFact::GuardianReview(input) => {
|
||||
self.ingest_guardian_review(*input, out);
|
||||
}
|
||||
@@ -604,6 +725,9 @@ impl AnalyticsReducer {
|
||||
CustomAnalyticsFact::PluginInstallFailed(input) => {
|
||||
self.ingest_plugin_install_failed(input, out);
|
||||
}
|
||||
CustomAnalyticsFact::PluginMeasurements(input) => {
|
||||
self.ingest_plugin_measurements(input, out);
|
||||
}
|
||||
CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input) => {
|
||||
self.ingest_external_agent_config_import_completed(input, out);
|
||||
}
|
||||
@@ -614,6 +738,16 @@ impl AnalyticsReducer {
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_artifact_operation(
|
||||
&mut self,
|
||||
input: ArtifactOperationInput,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
) {
|
||||
out.push(TrackEventRequest::ArtifactOperation(
|
||||
codex_artifact_operation_event_request(input.tracking, input.operation),
|
||||
));
|
||||
}
|
||||
|
||||
fn ingest_code_mode_tool_call(
|
||||
&mut self,
|
||||
input: CodeModeToolCallFact,
|
||||
@@ -628,8 +762,8 @@ impl AnalyticsReducer {
|
||||
};
|
||||
let has_thread_context = self.threads.get(thread_id).is_some_and(|thread| {
|
||||
thread.metadata.is_some()
|
||||
&& thread
|
||||
.connection_id
|
||||
&& self
|
||||
.thread_connection_id(thread_id)
|
||||
.is_some_and(|connection_id| self.connections.contains_key(&connection_id))
|
||||
});
|
||||
if !has_thread_context {
|
||||
@@ -712,6 +846,7 @@ impl AnalyticsReducer {
|
||||
CodeModeToolCallFact::Completed {
|
||||
thread_id,
|
||||
turn_id,
|
||||
turn_metadata,
|
||||
call_id,
|
||||
cell_id,
|
||||
tool_name,
|
||||
@@ -776,6 +911,7 @@ impl AnalyticsReducer {
|
||||
self.record_tool_event(
|
||||
&thread_id,
|
||||
&turn_id,
|
||||
turn_metadata.root_turn_id(),
|
||||
event,
|
||||
ToolEventEmission::AwaitResponse,
|
||||
out,
|
||||
@@ -784,6 +920,85 @@ impl AnalyticsReducer {
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_control_tool_call(
|
||||
&mut self,
|
||||
input: ControlToolCallFact,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
) {
|
||||
let ControlToolCallFact {
|
||||
thread_id,
|
||||
turn_id,
|
||||
turn_metadata,
|
||||
call_id,
|
||||
cell_id,
|
||||
tool_name,
|
||||
started_at_ms,
|
||||
completed_at_ms,
|
||||
status,
|
||||
} = input;
|
||||
let drop_site = AnalyticsDropSite {
|
||||
event_name: "control tool",
|
||||
thread_id: &thread_id,
|
||||
turn_id: Some(&turn_id),
|
||||
review_id: None,
|
||||
item_id: Some(&call_id),
|
||||
};
|
||||
let Some((connection_state, thread_state, thread_metadata)) =
|
||||
self.thread_context_or_warn(drop_site)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut base = tool_item_base(
|
||||
&thread_id,
|
||||
&turn_id,
|
||||
call_id,
|
||||
tool_name,
|
||||
ToolItemOutcome {
|
||||
terminal_status: match status {
|
||||
ControlToolCallStatus::Completed => ToolItemTerminalStatus::Completed,
|
||||
ControlToolCallStatus::Failed => ToolItemTerminalStatus::Failed,
|
||||
ControlToolCallStatus::Rejected => ToolItemTerminalStatus::Rejected,
|
||||
ControlToolCallStatus::Interrupted => ToolItemTerminalStatus::Interrupted,
|
||||
},
|
||||
failure_kind: match status {
|
||||
ControlToolCallStatus::Failed => Some(ToolItemFailureKind::ToolError),
|
||||
ControlToolCallStatus::Rejected => Some(ToolItemFailureKind::PolicyForbidden),
|
||||
ControlToolCallStatus::Completed | ControlToolCallStatus::Interrupted => None,
|
||||
},
|
||||
execution_duration_ms: observed_duration_ms(started_at_ms, completed_at_ms),
|
||||
},
|
||||
ToolItemContext {
|
||||
started_at_ms,
|
||||
completed_at_ms,
|
||||
connection_state,
|
||||
thread_state,
|
||||
thread_metadata,
|
||||
review_summary: None,
|
||||
},
|
||||
);
|
||||
base.cell_id = cell_id;
|
||||
let event = TrackEventRequest::ControlToolCall(CodexControlToolCallEventRequest {
|
||||
event_type: "codex_control_tool_call_event",
|
||||
event_params: CodexControlToolCallEventParams {
|
||||
base,
|
||||
success: status == ControlToolCallStatus::Completed,
|
||||
},
|
||||
});
|
||||
self.turns
|
||||
.entry(turn_id.clone())
|
||||
.or_default()
|
||||
.tool_counts
|
||||
.total += 1;
|
||||
self.record_tool_event(
|
||||
&thread_id,
|
||||
&turn_id,
|
||||
turn_metadata.root_turn_id(),
|
||||
event,
|
||||
ToolEventEmission::AwaitResponse,
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
fn ingest_sampling_response_completed(
|
||||
&mut self,
|
||||
thread_id: String,
|
||||
@@ -836,14 +1051,16 @@ impl AnalyticsReducer {
|
||||
&mut self,
|
||||
thread_id: &str,
|
||||
turn_id: &str,
|
||||
root_turn_id: Option<String>,
|
||||
mut event: TrackEventRequest,
|
||||
emission: ToolEventEmission,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
) {
|
||||
if tool_event_base_mut(&mut event).is_none() {
|
||||
let Some(base) = tool_event_base_mut(&mut event) else {
|
||||
out.push(event);
|
||||
return;
|
||||
}
|
||||
};
|
||||
base.root_turn_id = root_turn_id;
|
||||
let state = self
|
||||
.tool_response_states
|
||||
.entry((thread_id.to_string(), turn_id.to_string()))
|
||||
@@ -921,9 +1138,8 @@ impl AnalyticsReducer {
|
||||
) {
|
||||
let parent_thread_id = input.parent_thread_id.clone();
|
||||
let parent_connection_id = parent_thread_id
|
||||
.as_ref()
|
||||
.and_then(|parent_thread_id| self.threads.get(parent_thread_id))
|
||||
.and_then(|thread| thread.connection_id);
|
||||
.as_deref()
|
||||
.and_then(|parent_thread_id| self.thread_connection_id(parent_thread_id));
|
||||
let thread_state = self.threads.entry(input.thread_id.clone()).or_default();
|
||||
thread_state
|
||||
.originator
|
||||
@@ -932,7 +1148,7 @@ impl AnalyticsReducer {
|
||||
.metadata
|
||||
.get_or_insert_with(|| ThreadMetadataState {
|
||||
session_id: input.session_id.clone(),
|
||||
thread_source: Some(ThreadSource::Subagent),
|
||||
thread_source: input.thread_source.clone(),
|
||||
initialization_mode: ThreadInitializationMode::New,
|
||||
subagent_source: Some(subagent_source_name(&input.subagent_source)),
|
||||
parent_thread_id,
|
||||
@@ -940,9 +1156,13 @@ impl AnalyticsReducer {
|
||||
if thread_state.connection_id.is_none() {
|
||||
thread_state.connection_id = parent_connection_id;
|
||||
}
|
||||
out.push(TrackEventRequest::ThreadInitialized(
|
||||
subagent_thread_started_event_request(input),
|
||||
));
|
||||
// Guardian prewarm can register lineage before parent client metadata is set.
|
||||
// Keep the existing completeness requirement for the initialization event.
|
||||
if input.client_name.is_some() && input.client_version.is_some() {
|
||||
out.push(TrackEventRequest::ThreadInitialized(
|
||||
subagent_thread_started_event_request(input),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_guardian_review(
|
||||
@@ -1063,26 +1283,54 @@ impl AnalyticsReducer {
|
||||
invocations,
|
||||
} = input;
|
||||
for invocation in invocations {
|
||||
let skill_scope = match invocation.skill_scope {
|
||||
SkillScope::User => "user",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::System => "system",
|
||||
SkillScope::Admin => "admin",
|
||||
let (skill_id, repo_url, skill_scope) = match invocation.location {
|
||||
SkillInvocationLocation::Host { path, scope } => {
|
||||
let skill_scope = match scope {
|
||||
SkillScope::User => "user",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::System => "system",
|
||||
SkillScope::Admin => "admin",
|
||||
};
|
||||
let repo_root = get_git_repo_root(path.as_path());
|
||||
let repo_url = if let Some(root) = repo_root.as_ref() {
|
||||
collect_git_info(root)
|
||||
.await
|
||||
.and_then(|info| info.repository_url)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let skill_id = skill_id_for_local_skill(
|
||||
repo_url.as_ref().map(SanitizedGitUrl::as_str),
|
||||
repo_root.as_deref(),
|
||||
path.as_path(),
|
||||
invocation.skill_name.as_str(),
|
||||
);
|
||||
(skill_id, repo_url, Some(skill_scope.to_string()))
|
||||
}
|
||||
SkillInvocationLocation::Resource {
|
||||
id,
|
||||
skill_id,
|
||||
scope,
|
||||
} => {
|
||||
if matches!(invocation.invocation_type, InvocationType::Implicit) {
|
||||
let turn_state = self.turns.entry(tracking.turn_id.clone()).or_default();
|
||||
if !turn_state.resource_skill_invocations.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let skill_id = skill_id
|
||||
.unwrap_or_else(|| format!("{:x}", sha1::Sha1::digest(id.as_bytes())));
|
||||
let skill_scope = scope
|
||||
.map(|scope| match scope {
|
||||
SkillScope::User => "user",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::System => "system",
|
||||
SkillScope::Admin => "admin",
|
||||
})
|
||||
.map(str::to_owned);
|
||||
(skill_id, None, skill_scope)
|
||||
}
|
||||
};
|
||||
let repo_root = get_git_repo_root(invocation.skill_path.as_path());
|
||||
let repo_url = if let Some(root) = repo_root.as_ref() {
|
||||
collect_git_info(root)
|
||||
.await
|
||||
.and_then(|info| info.repository_url)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let skill_id = skill_id_for_local_skill(
|
||||
repo_url.as_deref(),
|
||||
repo_root.as_deref(),
|
||||
invocation.skill_path.as_path(),
|
||||
invocation.skill_name.as_str(),
|
||||
);
|
||||
out.push(TrackEventRequest::SkillInvocation(
|
||||
SkillInvocationEventRequest {
|
||||
event_type: "skill_invocation",
|
||||
@@ -1094,8 +1342,8 @@ impl AnalyticsReducer {
|
||||
invoke_type: Some(invocation.invocation_type),
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
product_client_id: Some(tracking.product_client_id.clone()),
|
||||
repo_url,
|
||||
skill_scope: Some(skill_scope.to_string()),
|
||||
repo_url: repo_url.map(String::from),
|
||||
skill_scope,
|
||||
plugin_id: invocation.plugin_id,
|
||||
remote_plugin_id: invocation.remote_plugin_id,
|
||||
},
|
||||
@@ -1321,6 +1569,10 @@ impl AnalyticsReducer {
|
||||
fn ingest_server_request(&mut self, _connection_id: u64, request: ServerRequest) {
|
||||
match request {
|
||||
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
|
||||
let is_stdin_review = match params.kind {
|
||||
CommandExecutionApprovalKind::WriteStdin => true,
|
||||
CommandExecutionApprovalKind::Command => false,
|
||||
};
|
||||
let is_network_access_review = params.network_approval_context.is_some();
|
||||
let requested_network_access = is_network_access_review
|
||||
|| params
|
||||
@@ -1334,7 +1586,9 @@ impl AnalyticsReducer {
|
||||
.and_then(|network| network.enabled)
|
||||
.unwrap_or(false);
|
||||
let requested_additional_permissions = params.additional_permissions.is_some();
|
||||
let trigger = if params.approval_id.is_some() {
|
||||
let trigger = if is_stdin_review {
|
||||
ReviewTrigger::Initial
|
||||
} else if params.approval_id.is_some() {
|
||||
ReviewTrigger::ExecveIntercept
|
||||
} else if requested_network_access {
|
||||
ReviewTrigger::NetworkPolicyDenial
|
||||
@@ -1353,12 +1607,16 @@ impl AnalyticsReducer {
|
||||
turn_id: params.turn_id,
|
||||
item_id: Some(params.item_id),
|
||||
review_id: user_review_id(&request_id),
|
||||
subject_kind: if is_network_access_review {
|
||||
subject_kind: if is_stdin_review {
|
||||
ReviewSubjectKind::WriteStdin
|
||||
} else if is_network_access_review {
|
||||
ReviewSubjectKind::NetworkAccess
|
||||
} else {
|
||||
ReviewSubjectKind::CommandExecution
|
||||
},
|
||||
subject_name: if is_network_access_review {
|
||||
subject_name: if is_stdin_review {
|
||||
"write_stdin".to_string()
|
||||
} else if is_network_access_review {
|
||||
"network_access".to_string()
|
||||
} else {
|
||||
"command_execution".to_string()
|
||||
@@ -1570,6 +1828,38 @@ impl AnalyticsReducer {
|
||||
);
|
||||
}
|
||||
|
||||
fn thread_archive_event_params(
|
||||
&self,
|
||||
thread_id: String,
|
||||
action: ThreadArchiveAction,
|
||||
) -> ThreadArchiveEventParams {
|
||||
let thread_state = self.threads.get(&thread_id);
|
||||
let connection_state = self
|
||||
.thread_connection_id(&thread_id)
|
||||
.and_then(|connection_id| self.connections.get(&connection_id));
|
||||
let thread_metadata = thread_state.and_then(|thread_state| thread_state.metadata.as_ref());
|
||||
|
||||
ThreadArchiveEventParams {
|
||||
thread_id,
|
||||
action,
|
||||
occurred_at_ms: now_unix_millis(),
|
||||
app_server_client: thread_state
|
||||
.zip(connection_state)
|
||||
.map(|(thread_state, connection_state)| {
|
||||
thread_state.app_server_client(connection_state)
|
||||
}),
|
||||
runtime: connection_state.map(|connection_state| connection_state.runtime.clone()),
|
||||
thread_source: thread_metadata
|
||||
.and_then(|thread_metadata| thread_metadata.thread_source.as_ref())
|
||||
.filter(|thread_source| {
|
||||
!matches!(thread_source, ThreadSource::Feature(feature) if feature != "automation")
|
||||
})
|
||||
.cloned(),
|
||||
parent_thread_id: thread_metadata
|
||||
.and_then(|thread_metadata| thread_metadata.parent_thread_id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ingest_notification(
|
||||
&mut self,
|
||||
notification: ServerNotification,
|
||||
@@ -1579,21 +1869,19 @@ impl AnalyticsReducer {
|
||||
ServerNotification::ThreadArchived(notification) => {
|
||||
out.push(TrackEventRequest::ThreadArchive(ThreadArchiveEvent {
|
||||
event_type: "codex_thread_archive_event",
|
||||
event_params: ThreadArchiveEventParams {
|
||||
thread_id: notification.thread_id,
|
||||
action: ThreadArchiveAction::Archived,
|
||||
occurred_at_ms: now_unix_millis(),
|
||||
},
|
||||
event_params: self.thread_archive_event_params(
|
||||
notification.thread_id,
|
||||
ThreadArchiveAction::Archived,
|
||||
),
|
||||
}));
|
||||
}
|
||||
ServerNotification::ThreadUnarchived(notification) => {
|
||||
out.push(TrackEventRequest::ThreadArchive(ThreadArchiveEvent {
|
||||
event_type: "codex_thread_archive_event",
|
||||
event_params: ThreadArchiveEventParams {
|
||||
thread_id: notification.thread_id,
|
||||
action: ThreadArchiveAction::Unarchived,
|
||||
occurred_at_ms: now_unix_millis(),
|
||||
},
|
||||
event_params: self.thread_archive_event_params(
|
||||
notification.thread_id,
|
||||
ThreadArchiveAction::Unarchived,
|
||||
),
|
||||
}));
|
||||
}
|
||||
ServerNotification::ItemStarted(notification) => {
|
||||
@@ -1673,15 +1961,37 @@ impl AnalyticsReducer {
|
||||
thread_metadata,
|
||||
review_summary: self.item_review_summaries.get(&key),
|
||||
}) {
|
||||
let root_turn_id = self
|
||||
.turns
|
||||
.get(¬ification.turn_id)
|
||||
.and_then(|turn| turn.resolved_config.as_ref())
|
||||
.and_then(|config| config.turn_metadata.root_turn_id());
|
||||
// Fast collaborator tools can complete before their sampling response.
|
||||
// Keep the event until that response can supply its originating ID.
|
||||
let emission =
|
||||
if matches!(¬ification.item, ThreadItem::CollabAgentToolCall { .. }) {
|
||||
ToolEventEmission::AwaitResponse
|
||||
} else {
|
||||
ToolEventEmission::ImmediateUnlessCorrelated
|
||||
};
|
||||
self.record_tool_event(
|
||||
¬ification.thread_id,
|
||||
¬ification.turn_id,
|
||||
root_turn_id,
|
||||
event,
|
||||
ToolEventEmission::ImmediateUnlessCorrelated,
|
||||
emission,
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.item_review_summaries.remove(&key);
|
||||
if self
|
||||
.turns
|
||||
.get(¬ification.turn_id)
|
||||
.is_some_and(|turn_state| turn_state.turn_event_emitted)
|
||||
&& !self.has_pending_tool_items_for_turn(¬ification.turn_id)
|
||||
{
|
||||
self.turns.remove(¬ification.turn_id);
|
||||
}
|
||||
}
|
||||
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
|
||||
let _ = notification;
|
||||
@@ -1738,6 +2048,50 @@ impl AnalyticsReducer {
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_plugin_measurements(
|
||||
&mut self,
|
||||
input: PluginMeasurementsInput,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
) {
|
||||
if input.rows.is_empty()
|
||||
|| input.rows.len() > MAX_PLUGIN_MEASUREMENTS_PER_BATCH
|
||||
|| !valid_plugin_measurement_identifier(&input.operation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let PluginMeasurementsInput {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
originator,
|
||||
plugin_id,
|
||||
execution_id,
|
||||
operation,
|
||||
rows,
|
||||
} = input;
|
||||
out.extend(
|
||||
rows.into_iter()
|
||||
.filter(valid_plugin_measurement_row)
|
||||
.map(|row| {
|
||||
TrackEventRequest::PluginMeasurement(CodexPluginMeasurementEventRequest {
|
||||
event_type: "codex_plugin_measurement_event",
|
||||
event_params: CodexPluginMeasurementEventParams {
|
||||
thread_id: thread_id.clone(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: item_id.clone(),
|
||||
originator: originator.clone(),
|
||||
plugin_id: plugin_id.clone(),
|
||||
execution_id: execution_id.clone(),
|
||||
operation: operation.clone(),
|
||||
measurement_name: row.measurement_name,
|
||||
number_value: row.number_value,
|
||||
dimensions: (!row.dimensions.is_empty()).then_some(row.dimensions),
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_thread_initialized(
|
||||
&mut self,
|
||||
connection_id: u64,
|
||||
@@ -2011,6 +2365,9 @@ impl AnalyticsReducer {
|
||||
let Some(turn_state) = self.turns.get(turn_id) else {
|
||||
return;
|
||||
};
|
||||
if turn_state.turn_event_emitted {
|
||||
return;
|
||||
}
|
||||
if turn_state.thread_id.is_none()
|
||||
|| turn_state.num_input_images.is_none()
|
||||
|| turn_state.resolved_config.is_none()
|
||||
@@ -2023,11 +2380,9 @@ impl AnalyticsReducer {
|
||||
return;
|
||||
};
|
||||
let drop_site = AnalyticsDropSite::turn(thread_id, turn_id);
|
||||
let connection_id = turn_state.connection_id.or_else(|| {
|
||||
self.threads
|
||||
.get(drop_site.thread_id)
|
||||
.and_then(|thread| thread.connection_id)
|
||||
});
|
||||
let connection_id = turn_state
|
||||
.connection_id
|
||||
.or_else(|| self.thread_connection_id(drop_site.thread_id));
|
||||
let Some(connection_id) = connection_id else {
|
||||
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection);
|
||||
return;
|
||||
@@ -2064,18 +2419,39 @@ impl AnalyticsReducer {
|
||||
input.repo_hash = accepted_line_repo_hash_for_cwd(cwd.as_path()).await;
|
||||
out.extend(accepted_line_fingerprint_event_requests(input));
|
||||
}
|
||||
self.turns.remove(turn_id);
|
||||
if self.has_pending_tool_items_for_turn(turn_id) {
|
||||
if let Some(turn_state) = self.turns.get_mut(turn_id) {
|
||||
turn_state.turn_event_emitted = true;
|
||||
}
|
||||
} else {
|
||||
self.turns.remove(turn_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn has_pending_tool_items_for_turn(&self, turn_id: &str) -> bool {
|
||||
self.tool_items_started_at_ms
|
||||
.keys()
|
||||
.any(|key| key.turn_id == turn_id)
|
||||
}
|
||||
|
||||
/// Resolve the parent connection lazily when a subagent fact arrives first.
|
||||
///
|
||||
/// Parents are spawned before their children, so ancestor links cannot cycle.
|
||||
fn thread_connection_id(&self, thread_id: &str) -> Option<u64> {
|
||||
let mut thread = self.threads.get(thread_id)?;
|
||||
while thread.connection_id.is_none() {
|
||||
let thread_metadata = thread.metadata.as_ref()?;
|
||||
let parent_thread_id = thread_metadata.parent_thread_id.as_deref()?;
|
||||
thread = self.threads.get(parent_thread_id)?;
|
||||
}
|
||||
thread.connection_id
|
||||
}
|
||||
|
||||
fn thread_connection_or_warn(
|
||||
&self,
|
||||
drop_site: AnalyticsDropSite<'_>,
|
||||
) -> Option<&ConnectionState> {
|
||||
let Some(thread_state) = self.threads.get(drop_site.thread_id) else {
|
||||
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection);
|
||||
return None;
|
||||
};
|
||||
let Some(connection_id) = thread_state.connection_id else {
|
||||
let Some(connection_id) = self.thread_connection_id(drop_site.thread_id) else {
|
||||
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection);
|
||||
return None;
|
||||
};
|
||||
@@ -2130,7 +2506,7 @@ fn warn_missing_analytics_context(
|
||||
);
|
||||
}
|
||||
|
||||
fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> {
|
||||
pub(crate) fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> {
|
||||
match item {
|
||||
ThreadItem::CommandExecution { id, .. }
|
||||
| ThreadItem::FileChange { id, .. }
|
||||
@@ -2142,6 +2518,7 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> {
|
||||
ThreadItem::UserMessage { .. }
|
||||
| ThreadItem::HookPrompt { .. }
|
||||
| ThreadItem::AgentMessage { .. }
|
||||
| ThreadItem::FunctionCallOutput { .. }
|
||||
| ThreadItem::Plan { .. }
|
||||
| ThreadItem::Reasoning { .. }
|
||||
| ThreadItem::SubAgentActivity { .. }
|
||||
@@ -2159,6 +2536,7 @@ fn tool_event_base_mut(event: &mut TrackEventRequest) -> Option<&mut CodexToolIt
|
||||
TrackEventRequest::FileChange(event) => Some(&mut event.event_params.base),
|
||||
TrackEventRequest::McpToolCall(event) => Some(&mut event.event_params.base),
|
||||
TrackEventRequest::DynamicToolCall(event) => Some(&mut event.event_params.base),
|
||||
TrackEventRequest::ControlToolCall(event) => Some(&mut event.event_params.base),
|
||||
TrackEventRequest::CollabAgentToolCall(event) => Some(&mut event.event_params.base),
|
||||
TrackEventRequest::WebSearch(event) => Some(&mut event.event_params.base),
|
||||
TrackEventRequest::ImageGeneration(event) => Some(&mut event.event_params.base),
|
||||
@@ -2205,7 +2583,9 @@ fn item_review_summary_key(pending_review: &PendingReviewState) -> Option<ToolIt
|
||||
turn_id: pending_review.turn_id.clone(),
|
||||
item_id: pending_review.item_id.clone()?,
|
||||
}),
|
||||
ReviewSubjectKind::Permissions | ReviewSubjectKind::NetworkAccess => None,
|
||||
ReviewSubjectKind::WriteStdin
|
||||
| ReviewSubjectKind::Permissions
|
||||
| ReviewSubjectKind::NetworkAccess => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2439,7 +2819,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
|
||||
ToolItemOutcome {
|
||||
terminal_status,
|
||||
failure_kind,
|
||||
execution_duration_ms: None,
|
||||
execution_duration_ms: observed_duration_ms(started_at_ms, completed_at_ms),
|
||||
},
|
||||
ToolItemContext {
|
||||
started_at_ms,
|
||||
@@ -2544,6 +2924,8 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
|
||||
base,
|
||||
revised_prompt_present: item.revised_prompt.is_some(),
|
||||
saved_path_present: item.saved_path.is_some(),
|
||||
transparent_background: item.transparent_background,
|
||||
imagegen_request_id: item.imagegen_request_id.clone(),
|
||||
},
|
||||
},
|
||||
))
|
||||
@@ -2615,6 +2997,7 @@ fn tool_item_base(
|
||||
thread_id: thread_id.to_string(),
|
||||
session_id: thread_metadata.session_id.clone(),
|
||||
turn_id: turn_id.to_string(),
|
||||
root_turn_id: None,
|
||||
item_id,
|
||||
cell_id: None,
|
||||
parent_call_id: None,
|
||||
@@ -2744,6 +3127,11 @@ fn guardian_review_subject_metadata(
|
||||
"command_execution".to_string(),
|
||||
ReviewTrigger::Initial,
|
||||
),
|
||||
GuardianApprovalReviewAction::WriteStdin { .. } => (
|
||||
ReviewSubjectKind::WriteStdin,
|
||||
"write_stdin".to_string(),
|
||||
ReviewTrigger::Initial,
|
||||
),
|
||||
GuardianApprovalReviewAction::Execve { .. } => (
|
||||
ReviewSubjectKind::CommandExecution,
|
||||
"command_execution".to_string(),
|
||||
@@ -2795,6 +3183,7 @@ fn guardian_review_requested_additional_permissions(action: &GuardianApprovalRev
|
||||
|| permissions.file_system.is_some()
|
||||
}
|
||||
GuardianApprovalReviewAction::Command { .. }
|
||||
| GuardianApprovalReviewAction::WriteStdin { .. }
|
||||
| GuardianApprovalReviewAction::Execve { .. }
|
||||
| GuardianApprovalReviewAction::McpToolCall { .. } => false,
|
||||
}
|
||||
@@ -2808,6 +3197,7 @@ fn guardian_review_requested_network_access(action: &GuardianApprovalReviewActio
|
||||
}
|
||||
GuardianApprovalReviewAction::ApplyPatch { .. }
|
||||
| GuardianApprovalReviewAction::Command { .. }
|
||||
| GuardianApprovalReviewAction::WriteStdin { .. }
|
||||
| GuardianApprovalReviewAction::Execve { .. }
|
||||
| GuardianApprovalReviewAction::McpToolCall { .. } => false,
|
||||
}
|
||||
@@ -2916,6 +3306,7 @@ fn collab_tool_call_outcome(
|
||||
match status {
|
||||
CollabAgentToolCallStatus::InProgress => None,
|
||||
CollabAgentToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)),
|
||||
CollabAgentToolCallStatus::Interrupted => Some((ToolItemTerminalStatus::Interrupted, None)),
|
||||
CollabAgentToolCallStatus::Failed => Some((
|
||||
ToolItemTerminalStatus::Failed,
|
||||
Some(ToolItemFailureKind::ToolError),
|
||||
@@ -2940,6 +3331,10 @@ fn collab_agent_tool_name(tool: &CollabAgentTool) -> &'static str {
|
||||
CollabAgentTool::ResumeAgent => "resume_agent",
|
||||
CollabAgentTool::Wait => "wait_agent",
|
||||
CollabAgentTool::CloseAgent => "close_agent",
|
||||
CollabAgentTool::SendMessage => "send_message",
|
||||
CollabAgentTool::FollowupTask => "followup_task",
|
||||
CollabAgentTool::InterruptAgent => "interrupt_agent",
|
||||
CollabAgentTool::ListAgents => "list_agents",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3018,7 +3413,7 @@ fn accepted_line_event_input(
|
||||
turn_state: &TurnState,
|
||||
) -> Option<(AcceptedLineFingerprintEventInput, PathBuf)> {
|
||||
let latest_diff = turn_state.latest_diff.as_deref()?;
|
||||
let summary = accepted_line_fingerprints_from_unified_diff(latest_diff);
|
||||
let summary = accepted_line_counts_from_unified_diff(latest_diff);
|
||||
if summary.accepted_added_lines == 0 && summary.accepted_deleted_lines == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -3037,7 +3432,6 @@ fn accepted_line_event_input(
|
||||
repo_hash: None,
|
||||
accepted_added_lines: summary.accepted_added_lines,
|
||||
accepted_deleted_lines: summary.accepted_deleted_lines,
|
||||
line_fingerprints: summary.line_fingerprints,
|
||||
},
|
||||
resolved_config.permission_profile_cwd,
|
||||
))
|
||||
@@ -3070,6 +3464,7 @@ fn codex_turn_event_params(
|
||||
let TurnResolvedConfigFact {
|
||||
turn_id: _resolved_turn_id,
|
||||
thread_id: _resolved_thread_id,
|
||||
turn_metadata,
|
||||
num_input_images: _resolved_num_input_images,
|
||||
submission_type,
|
||||
ephemeral,
|
||||
@@ -3083,6 +3478,7 @@ fn codex_turn_event_params(
|
||||
service_tier,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
guardian_v2_enabled,
|
||||
sandbox_network_access,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
@@ -3105,6 +3501,9 @@ fn codex_turn_event_params(
|
||||
thread_id,
|
||||
session_id: thread_metadata.session_id.clone(),
|
||||
turn_id,
|
||||
root_turn_id: turn_metadata.root_turn_id(),
|
||||
turn_trigger: turn_metadata.turn_trigger(),
|
||||
codex_turn_source: turn_metadata.codex_turn_source(),
|
||||
app_server_client,
|
||||
runtime,
|
||||
submission_type,
|
||||
@@ -3126,6 +3525,7 @@ fn codex_turn_event_params(
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
approval_policy: approval_policy.to_string(),
|
||||
approvals_reviewer: approvals_reviewer.to_string(),
|
||||
guardian_v2_enabled,
|
||||
sandbox_network_access,
|
||||
collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)),
|
||||
personality: personality_mode(personality),
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
//! Per-attempt thread hint status analytics without hint contents.
|
||||
|
||||
use crate::events::CodexAppServerClientMetadata;
|
||||
use crate::events::CodexRuntimeMetadata;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadHintStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct ThreadHintStatusEvent {
|
||||
pub thread_id: String,
|
||||
pub status: ThreadHintStatus,
|
||||
pub occurred_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ThreadHintStatusEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: ThreadHintStatusEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ThreadHintStatusEventParams {
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
||||
pub(crate) runtime: CodexRuntimeMetadata,
|
||||
pub(crate) thread_source: Option<ThreadSource>,
|
||||
pub(crate) subagent_source: Option<String>,
|
||||
pub(crate) parent_thread_id: Option<String>,
|
||||
pub(crate) status: ThreadHintStatus,
|
||||
pub(crate) occurred_at_ms: u64,
|
||||
}
|
||||
+5
-6
@@ -57,11 +57,10 @@ phase where they reconcile startup response data with later events.
|
||||
|
||||
## Backpressure and shutdown
|
||||
|
||||
- Queues are bounded and use `DEFAULT_IN_PROCESS_CHANNEL_CAPACITY` by default.
|
||||
- Full queues return explicit overload behavior instead of unbounded growth.
|
||||
- Command queues and the embedded runtime remain bounded, using
|
||||
`DEFAULT_IN_PROCESS_CHANNEL_CAPACITY` by default.
|
||||
- The facade's local consumer event queue is unbounded and preserves notification
|
||||
order. This keeps the worker draining the bounded runtime while a caller waits
|
||||
for a request, preventing unread notifications from blocking its response.
|
||||
- `shutdown()` performs a bounded graceful shutdown and then aborts if timeout
|
||||
is exceeded.
|
||||
|
||||
If the client falls behind on event consumption, the worker emits
|
||||
`InProcessServerEvent::Lagged` and may reject pending server requests so
|
||||
approval flows do not hang indefinitely behind a saturated queue.
|
||||
|
||||
+171
-391
@@ -7,13 +7,14 @@
|
||||
//! - Typed caller-provided startup identity (`SessionSource` + client name).
|
||||
//! - Typed and raw request/notification dispatch.
|
||||
//! - Server request resolution and rejection.
|
||||
//! - Event consumption with backpressure signaling ([`InProcessServerEvent::Lagged`]).
|
||||
//! - Ordered, lossless event consumption that cannot block request processing.
|
||||
//! - Bounded graceful shutdown with abort fallback.
|
||||
//!
|
||||
//! The facade interposes a worker task between the caller and the underlying
|
||||
//! [`InProcessClientHandle`](codex_app_server::in_process::InProcessClientHandle),
|
||||
//! bridging async `mpsc` channels on both sides. Queues are bounded so overload
|
||||
//! surfaces as channel-full errors rather than unbounded memory growth.
|
||||
//! bridging async `mpsc` channels on both sides. Commands and the underlying
|
||||
//! runtime remain bounded; the local consumer event queue is unbounded so
|
||||
//! unread notifications cannot prevent request responses from being delivered.
|
||||
|
||||
mod path;
|
||||
mod remote;
|
||||
@@ -47,8 +48,6 @@ use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
use codex_config::RemoteThreadConfigLoader;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
use codex_core::config::Config;
|
||||
pub use codex_core::otel_init::build_provider as build_otel_provider;
|
||||
pub use codex_exec_server::EnvironmentManager;
|
||||
@@ -114,131 +113,6 @@ impl From<InProcessServerEvent> for AppServerEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn event_requires_delivery(event: &InProcessServerEvent) -> bool {
|
||||
// These transcript and terminal events must remain lossless. Dropping
|
||||
// streamed assistant text or the authoritative completed item can leave
|
||||
// the TUI with permanently corrupted markdown, while dropping completion
|
||||
// notifications can leave surfaces waiting forever.
|
||||
match event {
|
||||
InProcessServerEvent::ServerNotification(notification) => {
|
||||
server_notification_requires_delivery(notification)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` for notifications that must survive backpressure.
|
||||
///
|
||||
/// Transcript events (`AgentMessageDelta`, `PlanDelta`, reasoning deltas) and
|
||||
/// the authoritative `ItemCompleted` / `TurnCompleted` form the lossless tier
|
||||
/// of the event stream. Dropping any of these corrupts the visible assistant
|
||||
/// output or leaves surfaces waiting for a completion signal that already
|
||||
/// fired. Everything else (`CommandExecutionOutputDelta`, progress, etc.) is
|
||||
/// best-effort and may be dropped with only cosmetic impact.
|
||||
///
|
||||
/// Both the in-process and remote transports delegate to this function so the
|
||||
/// classification stays in sync.
|
||||
pub(crate) fn server_notification_requires_delivery(notification: &ServerNotification) -> bool {
|
||||
matches!(
|
||||
notification,
|
||||
ServerNotification::TurnCompleted(_)
|
||||
| ServerNotification::ThreadSettingsUpdated(_)
|
||||
| ServerNotification::ItemCompleted(_)
|
||||
| ServerNotification::ExternalAgentConfigImportCompleted(_)
|
||||
| ServerNotification::AgentMessageDelta(_)
|
||||
| ServerNotification::PlanDelta(_)
|
||||
| ServerNotification::ReasoningSummaryTextDelta(_)
|
||||
| ServerNotification::ReasoningTextDelta(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Outcome of attempting to forward a single event to the consumer channel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ForwardEventResult {
|
||||
/// The event was delivered (or intentionally dropped); the stream is healthy.
|
||||
Continue,
|
||||
/// The consumer channel is closed; the caller should stop producing events.
|
||||
DisableStream,
|
||||
}
|
||||
|
||||
/// Forwards a single in-process event to the consumer, respecting the
|
||||
/// lossless/best-effort split.
|
||||
///
|
||||
/// Lossless events (transcript deltas, item/turn completions) block until the
|
||||
/// consumer drains capacity. Best-effort events use `try_send` and increment
|
||||
/// `skipped_events` on failure. When a lag marker needs to be flushed before a
|
||||
/// lossless event, the flush itself blocks so the marker is never lost.
|
||||
///
|
||||
/// If a dropped event is a `ServerRequest`, `reject_server_request` is called
|
||||
/// so the server does not wait for a response that will never come.
|
||||
async fn forward_in_process_event<F>(
|
||||
event_tx: &mpsc::Sender<InProcessServerEvent>,
|
||||
skipped_events: &mut usize,
|
||||
event: InProcessServerEvent,
|
||||
mut reject_server_request: F,
|
||||
) -> ForwardEventResult
|
||||
where
|
||||
F: FnMut(ServerRequest),
|
||||
{
|
||||
if *skipped_events > 0 {
|
||||
if event_requires_delivery(&event) {
|
||||
// Surface lag before the lossless event, but do not let the lag marker itself cause
|
||||
// us to drop the transcript/completion notification the caller is blocked on.
|
||||
if event_tx
|
||||
.send(InProcessServerEvent::Lagged {
|
||||
skipped: *skipped_events,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return ForwardEventResult::DisableStream;
|
||||
}
|
||||
*skipped_events = 0;
|
||||
} else {
|
||||
match event_tx.try_send(InProcessServerEvent::Lagged {
|
||||
skipped: *skipped_events,
|
||||
}) {
|
||||
Ok(()) => {
|
||||
*skipped_events = 0;
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
*skipped_events = skipped_events.saturating_add(1);
|
||||
warn!("dropping in-process app-server event because consumer queue is full");
|
||||
if let InProcessServerEvent::ServerRequest(request) = event {
|
||||
reject_server_request(*request);
|
||||
}
|
||||
return ForwardEventResult::Continue;
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
return ForwardEventResult::DisableStream;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event_requires_delivery(&event) {
|
||||
// Block until the consumer catches up for transcript/completion notifications; this
|
||||
// preserves the visible assistant output even when the queue is otherwise saturated.
|
||||
if event_tx.send(event).await.is_err() {
|
||||
return ForwardEventResult::DisableStream;
|
||||
}
|
||||
return ForwardEventResult::Continue;
|
||||
}
|
||||
|
||||
match event_tx.try_send(event) {
|
||||
Ok(()) => ForwardEventResult::Continue,
|
||||
Err(mpsc::error::TrySendError::Full(event)) => {
|
||||
*skipped_events = skipped_events.saturating_add(1);
|
||||
warn!("dropping in-process app-server event because consumer queue is full");
|
||||
if let InProcessServerEvent::ServerRequest(request) = event {
|
||||
reject_server_request(*request);
|
||||
}
|
||||
ForwardEventResult::Continue
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => ForwardEventResult::DisableStream,
|
||||
}
|
||||
}
|
||||
|
||||
/// Layered error for [`InProcessAppServerClient::request_typed`].
|
||||
///
|
||||
/// This keeps transport failures, server-side JSON-RPC failures, and response
|
||||
@@ -332,17 +206,10 @@ pub struct InProcessClientStartArgs {
|
||||
pub mcp_server_openai_form_elicitation: bool,
|
||||
/// Notification methods this client opts out of receiving.
|
||||
pub opt_out_notification_methods: Vec<String>,
|
||||
/// Queue capacity for command/event channels (clamped to at least 1).
|
||||
/// Queue capacity for command and embedded-runtime channels (clamped to at least 1).
|
||||
pub channel_capacity: usize,
|
||||
}
|
||||
|
||||
fn configured_thread_config_loader(config: &Config) -> Arc<dyn ThreadConfigLoader> {
|
||||
match config.experimental_thread_config_endpoint.as_deref() {
|
||||
Some(endpoint) => Arc::new(RemoteThreadConfigLoader::new(endpoint)),
|
||||
None => Arc::new(NoopThreadConfigLoader),
|
||||
}
|
||||
}
|
||||
|
||||
impl InProcessClientStartArgs {
|
||||
/// Builds initialize params from caller-provided metadata.
|
||||
pub fn initialize_params(&self) -> InitializeParams {
|
||||
@@ -370,7 +237,6 @@ impl InProcessClientStartArgs {
|
||||
|
||||
fn into_runtime_start_args(self) -> InProcessStartArgs {
|
||||
let initialize = self.initialize_params();
|
||||
let thread_config_loader = configured_thread_config_loader(&self.config);
|
||||
InProcessStartArgs {
|
||||
arg0_paths: self.arg0_paths,
|
||||
config: self.config,
|
||||
@@ -378,7 +244,7 @@ impl InProcessClientStartArgs {
|
||||
loader_overrides: self.loader_overrides,
|
||||
strict_config: self.strict_config,
|
||||
cloud_config_bundle: self.cloud_config_bundle,
|
||||
thread_config_loader,
|
||||
thread_config_loader: Arc::new(NoopThreadConfigLoader),
|
||||
feedback: self.feedback,
|
||||
log_db: self.log_db,
|
||||
state_db: self.state_db,
|
||||
@@ -433,7 +299,7 @@ enum ClientCommand {
|
||||
/// boundary.
|
||||
pub struct InProcessAppServerClient {
|
||||
command_tx: mpsc::Sender<ClientCommand>,
|
||||
event_rx: mpsc::Receiver<InProcessServerEvent>,
|
||||
event_rx: mpsc::UnboundedReceiver<InProcessServerEvent>,
|
||||
worker_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -456,20 +322,22 @@ pub enum AppServerClient {
|
||||
impl InProcessAppServerClient {
|
||||
/// Starts the in-process runtime and facade worker task.
|
||||
///
|
||||
/// The returned client is ready for requests and event consumption. If the
|
||||
/// internal event queue is saturated later, server requests are rejected
|
||||
/// with overload error instead of being silently dropped.
|
||||
/// The returned client is ready for requests and ordered event consumption.
|
||||
/// Request queues remain bounded without blocking on unread notifications.
|
||||
pub async fn start(args: InProcessClientStartArgs) -> IoResult<Self> {
|
||||
let channel_capacity = args.channel_capacity.max(1);
|
||||
let mut handle =
|
||||
codex_app_server::in_process::start(args.into_runtime_start_args()).await?;
|
||||
let request_sender = handle.sender();
|
||||
let (command_tx, mut command_rx) = mpsc::channel::<ClientCommand>(channel_capacity);
|
||||
let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(channel_capacity);
|
||||
// e9996ec62a preserved transcript events by awaiting a bounded queue, but that can
|
||||
// deadlock a foreground request whose response is behind unread notifications.
|
||||
// Match the remote-client fix in 79ea57715636: only this local consumer queue is
|
||||
// unbounded; commands and the embedded runtime stay bounded and events remain ordered.
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<InProcessServerEvent>();
|
||||
|
||||
let worker_handle = tokio::spawn(async move {
|
||||
let mut event_stream_enabled = true;
|
||||
let mut skipped_events = 0usize;
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = command_rx.recv() => {
|
||||
@@ -543,28 +411,8 @@ impl InProcessAppServerClient {
|
||||
continue;
|
||||
}
|
||||
|
||||
match forward_in_process_event(
|
||||
&event_tx,
|
||||
&mut skipped_events,
|
||||
event,
|
||||
|request| {
|
||||
let _ = request_sender.fail_server_request(
|
||||
request.id().clone(),
|
||||
JSONRPCErrorError {
|
||||
code: -32001,
|
||||
message: "in-process app-server event queue is full"
|
||||
.to_string(),
|
||||
data: None,
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
ForwardEventResult::Continue => {}
|
||||
ForwardEventResult::DisableStream => {
|
||||
event_stream_enabled = false;
|
||||
}
|
||||
if event_tx.send(event).is_err() {
|
||||
event_stream_enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -722,9 +570,7 @@ impl InProcessAppServerClient {
|
||||
|
||||
/// Returns the next in-process event, or `None` when worker exits.
|
||||
///
|
||||
/// Callers are expected to drain this stream promptly. If they fall behind,
|
||||
/// the worker emits [`InProcessServerEvent::Lagged`] markers and may reject
|
||||
/// pending server requests rather than letting approval flows hang.
|
||||
/// Events remain ordered and are retained while callers await requests.
|
||||
pub async fn next_event(&mut self) -> Option<InProcessServerEvent> {
|
||||
self.event_rx.recv().await
|
||||
}
|
||||
@@ -740,10 +586,7 @@ impl InProcessAppServerClient {
|
||||
worker_handle,
|
||||
} = self;
|
||||
let mut worker_handle = worker_handle;
|
||||
// Drop the caller-facing receiver before asking the worker to shut
|
||||
// down. That unblocks any pending must-deliver `event_tx.send(..)`
|
||||
// so the worker can reach `handle.shutdown()` instead of timing out
|
||||
// and getting aborted with the runtime still attached.
|
||||
// Stop forwarding caller-facing events before asking the worker to shut down.
|
||||
drop(event_rx);
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
if command_tx
|
||||
@@ -834,6 +677,23 @@ impl AppServerRequestHandle {
|
||||
}
|
||||
|
||||
impl AppServerClient {
|
||||
/// App-server platform family, which can differ from the executor's platform.
|
||||
/// Older remote servers may omit this metadata.
|
||||
pub fn platform_family(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::InProcess(_) => Some(std::env::consts::FAMILY),
|
||||
Self::Remote(client) => client.platform_family(),
|
||||
}
|
||||
}
|
||||
|
||||
/// App-server operating system as reported at initialization, including unknown values.
|
||||
pub fn platform_os(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::InProcess(_) => Some(std::env::consts::OS),
|
||||
Self::Remote(client) => client.platform_os(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn codex_home(&self, local_codex_home: &AbsolutePathBuf) -> Option<AppServerPath> {
|
||||
match self {
|
||||
Self::InProcess(_) => Some(AppServerPath::from_app_server(
|
||||
@@ -922,12 +782,15 @@ mod tests {
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::SessionSource as ApiSessionSource;
|
||||
use codex_app_server_protocol::ThreadSettingsUpdateParams;
|
||||
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::init_state_db;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_uds::UnixListener;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use futures::SinkExt;
|
||||
@@ -1084,6 +947,22 @@ mod tests {
|
||||
async fn expect_remote_initialize<S>(websocket: &mut tokio_tungstenite::WebSocketStream<S>)
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
expect_remote_initialize_with_metadata(
|
||||
websocket,
|
||||
serde_json::json!({
|
||||
"userAgent": "codex_cli_rs/9.8.7-test (Test OS; x86_64) rust",
|
||||
"codexHome": "/server/.codex",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn expect_remote_initialize_with_metadata<S>(
|
||||
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
|
||||
metadata: serde_json::Value,
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let JSONRPCMessage::Request(request) = read_websocket_message(websocket).await else {
|
||||
panic!("expected initialize request");
|
||||
@@ -1093,10 +972,7 @@ mod tests {
|
||||
websocket,
|
||||
JSONRPCMessage::Response(JSONRPCResponse {
|
||||
id: request.id,
|
||||
result: serde_json::json!({
|
||||
"userAgent": "codex_cli_rs/9.8.7-test (Test OS; x86_64) rust",
|
||||
"codexHome": "/server/.codex",
|
||||
}),
|
||||
result: metadata,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -1181,6 +1057,8 @@ mod tests {
|
||||
text: text.to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
delivery: None,
|
||||
questions: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1231,7 +1109,15 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_request_roundtrip_works() {
|
||||
let client = start_test_client(SessionSource::Exec).await;
|
||||
let TestClient {
|
||||
_codex_home,
|
||||
client,
|
||||
} = start_test_client(SessionSource::Exec).await;
|
||||
let client = AppServerClient::InProcess(client);
|
||||
assert_eq!(
|
||||
(client.platform_family(), client.platform_os()),
|
||||
(Some(std::env::consts::FAMILY), Some(std::env::consts::OS))
|
||||
);
|
||||
let _response: ConfigRequirementsReadResponse = client
|
||||
.request_typed(ClientRequest::ConfigRequirementsRead {
|
||||
request_id: RequestId::Integer(1),
|
||||
@@ -1330,105 +1216,116 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_in_process_event_preserves_transcript_notifications_under_backpressure() {
|
||||
let (event_tx, mut event_rx) = mpsc::channel(1);
|
||||
event_tx
|
||||
.send(InProcessServerEvent::ServerNotification(Box::new(
|
||||
command_execution_output_delta_notification("stdout-1"),
|
||||
)))
|
||||
async fn unread_lossless_notifications_do_not_block_in_process_requests() {
|
||||
let mut client =
|
||||
start_test_client_with_capacity(SessionSource::Cli, /*channel_capacity*/ 1).await;
|
||||
let thread: ThreadStartResponse = client
|
||||
.request_typed(ClientRequest::ThreadStart {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadStartParams {
|
||||
ephemeral: Some(true),
|
||||
personality: Some(Personality::None),
|
||||
..ThreadStartParams::default()
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("initial event should enqueue");
|
||||
.expect("thread/start should succeed");
|
||||
let request_handle = client.request_handle();
|
||||
|
||||
let mut skipped_events = 0usize;
|
||||
let result = forward_in_process_event(
|
||||
&event_tx,
|
||||
&mut skipped_events,
|
||||
InProcessServerEvent::ServerNotification(Box::new(
|
||||
command_execution_output_delta_notification("stdout-2"),
|
||||
)),
|
||||
|_| {},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result, ForwardEventResult::Continue);
|
||||
assert_eq!(skipped_events, 1);
|
||||
|
||||
let receive_task = tokio::spawn(async move {
|
||||
let mut events = Vec::new();
|
||||
for _ in 0..5 {
|
||||
events.push(
|
||||
timeout(Duration::from_secs(2), event_rx.recv())
|
||||
.await
|
||||
.expect("event should arrive before timeout")
|
||||
.expect("event stream should stay open"),
|
||||
);
|
||||
timeout(Duration::from_secs(2), async {
|
||||
for (index, personality) in [
|
||||
Personality::Friendly,
|
||||
Personality::Pragmatic,
|
||||
Personality::Friendly,
|
||||
Personality::Pragmatic,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let _: ThreadSettingsUpdateResponse = request_handle
|
||||
.request_typed(ClientRequest::ThreadSettingsUpdate {
|
||||
request_id: RequestId::Integer((index + 2) as i64),
|
||||
params: ThreadSettingsUpdateParams {
|
||||
thread_id: thread.thread.id.clone(),
|
||||
personality: Some(personality),
|
||||
..ThreadSettingsUpdateParams::default()
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("thread/settings/update should succeed");
|
||||
}
|
||||
events
|
||||
});
|
||||
|
||||
for notification in [
|
||||
agent_message_delta_notification("hello"),
|
||||
item_completed_notification("hello"),
|
||||
turn_completed_notification(),
|
||||
let _: ConfigRequirementsReadResponse = request_handle
|
||||
.request_typed(ClientRequest::ConfigRequirementsRead {
|
||||
request_id: RequestId::Integer(10),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
.expect("configuration request should succeed");
|
||||
})
|
||||
.await
|
||||
.expect("unread lossless notifications must not block app-server requests");
|
||||
|
||||
let mut personalities = Vec::new();
|
||||
timeout(Duration::from_secs(2), async {
|
||||
while personalities.len() < 4 {
|
||||
if let Some(InProcessServerEvent::ServerNotification(notification)) =
|
||||
client.client.next_event().await
|
||||
&& let ServerNotification::ThreadSettingsUpdated(notification) =
|
||||
notification.as_ref()
|
||||
{
|
||||
personalities.push(notification.thread_settings.personality);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("queued settings notifications should remain readable");
|
||||
assert_eq!(
|
||||
personalities,
|
||||
vec![
|
||||
Some(Personality::Friendly),
|
||||
Some(Personality::Pragmatic),
|
||||
Some(Personality::Friendly),
|
||||
Some(Personality::Pragmatic),
|
||||
]
|
||||
);
|
||||
|
||||
client.shutdown().await.expect("shutdown should complete");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_platform_metadata_preserves_reported_and_missing_values() {
|
||||
for (family, os) in [
|
||||
(Some("windows"), Some("windows")),
|
||||
(Some("unix"), Some("linux")),
|
||||
(Some("future-family"), Some("future-os")),
|
||||
(Some("unix"), None),
|
||||
(None, Some("linux")),
|
||||
(None, None),
|
||||
] {
|
||||
let result = forward_in_process_event(
|
||||
&event_tx,
|
||||
&mut skipped_events,
|
||||
InProcessServerEvent::ServerNotification(Box::new(notification)),
|
||||
|_| {},
|
||||
)
|
||||
let websocket_url = start_test_remote_server(move |mut websocket| async move {
|
||||
let mut metadata = serde_json::json!({});
|
||||
if let Some(family) = family {
|
||||
metadata["platformFamily"] = family.into();
|
||||
}
|
||||
if let Some(os) = os {
|
||||
metadata["platformOs"] = os.into();
|
||||
}
|
||||
expect_remote_initialize_with_metadata(&mut websocket, metadata).await;
|
||||
websocket.close(None).await.expect("close should succeed");
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result, ForwardEventResult::Continue);
|
||||
let client = AppServerClient::Remote(
|
||||
RemoteAppServerClient::connect(test_remote_connect_args(websocket_url))
|
||||
.await
|
||||
.expect("remote client should connect"),
|
||||
);
|
||||
assert_eq!(
|
||||
(client.platform_family(), client.platform_os()),
|
||||
(family, os)
|
||||
);
|
||||
client.shutdown().await.expect("shutdown should complete");
|
||||
}
|
||||
assert_eq!(skipped_events, 0);
|
||||
|
||||
let events = receive_task
|
||||
.await
|
||||
.expect("receiver task should join successfully");
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
InProcessServerEvent::ServerNotification(notification)
|
||||
if matches!(
|
||||
notification.as_ref(),
|
||||
ServerNotification::CommandExecutionOutputDelta(notification)
|
||||
if notification.delta == "stdout-1"
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
InProcessServerEvent::Lagged { skipped: 1 }
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[2],
|
||||
InProcessServerEvent::ServerNotification(notification)
|
||||
if matches!(
|
||||
notification.as_ref(),
|
||||
ServerNotification::AgentMessageDelta(notification)
|
||||
if notification.delta == "hello"
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[3],
|
||||
InProcessServerEvent::ServerNotification(notification)
|
||||
if matches!(
|
||||
notification.as_ref(),
|
||||
ServerNotification::ItemCompleted(notification)
|
||||
if matches!(
|
||||
¬ification.item,
|
||||
codex_app_server_protocol::ThreadItem::AgentMessage { text, .. }
|
||||
if text == "hello"
|
||||
)
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[4],
|
||||
InProcessServerEvent::ServerNotification(notification)
|
||||
if matches!(
|
||||
notification.as_ref(),
|
||||
ServerNotification::TurnCompleted(notification)
|
||||
if notification.turn.status
|
||||
== codex_app_server_protocol::TurnStatus::Completed
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2097,11 +1994,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn next_event_surfaces_lagged_markers() {
|
||||
let (command_tx, _command_rx) = mpsc::channel(1);
|
||||
let (event_tx, event_rx) = mpsc::channel(1);
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let worker_handle = tokio::spawn(async {});
|
||||
event_tx
|
||||
.send(InProcessServerEvent::Lagged { skipped: 3 })
|
||||
.await
|
||||
.expect("lagged marker should enqueue");
|
||||
drop(event_tx);
|
||||
|
||||
@@ -2122,83 +2018,6 @@ mod tests {
|
||||
client.shutdown().await.expect("shutdown should complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_requires_delivery_marks_transcript_and_terminal_events() {
|
||||
assert!(event_requires_delivery(
|
||||
&InProcessServerEvent::ServerNotification(Box::new(
|
||||
codex_app_server_protocol::ServerNotification::TurnCompleted(
|
||||
codex_app_server_protocol::TurnCompletedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn: codex_app_server_protocol::Turn {
|
||||
id: "turn".to_string(),
|
||||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||||
items: Vec::new(),
|
||||
status: codex_app_server_protocol::TurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: Some(0),
|
||||
duration_ms: None,
|
||||
},
|
||||
}
|
||||
)
|
||||
))
|
||||
));
|
||||
assert!(event_requires_delivery(
|
||||
&InProcessServerEvent::ServerNotification(Box::new(
|
||||
codex_app_server_protocol::ServerNotification::AgentMessageDelta(
|
||||
codex_app_server_protocol::AgentMessageDeltaNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
item_id: "item".to_string(),
|
||||
delta: "hello".to_string(),
|
||||
}
|
||||
)
|
||||
))
|
||||
));
|
||||
assert!(event_requires_delivery(
|
||||
&InProcessServerEvent::ServerNotification(Box::new(
|
||||
codex_app_server_protocol::ServerNotification::ItemCompleted(
|
||||
codex_app_server_protocol::ItemCompletedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
completed_at_ms: 0,
|
||||
item: codex_app_server_protocol::ThreadItem::AgentMessage {
|
||||
id: "item".to_string(),
|
||||
text: "hello".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
},
|
||||
}
|
||||
)
|
||||
))
|
||||
));
|
||||
assert!(event_requires_delivery(
|
||||
&InProcessServerEvent::ServerNotification(Box::new(
|
||||
codex_app_server_protocol::ServerNotification::ExternalAgentConfigImportCompleted(
|
||||
codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification {
|
||||
import_id: "import".to_string(),
|
||||
item_type_results: Vec::new(),
|
||||
},
|
||||
)
|
||||
))
|
||||
));
|
||||
assert!(!event_requires_delivery(&InProcessServerEvent::Lagged {
|
||||
skipped: 1
|
||||
}));
|
||||
assert!(!event_requires_delivery(
|
||||
&InProcessServerEvent::ServerNotification(Box::new(
|
||||
codex_app_server_protocol::ServerNotification::CommandExecutionOutputDelta(
|
||||
codex_app_server_protocol::CommandExecutionOutputDeltaNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
item_id: "item".to_string(),
|
||||
delta: "stdout".to_string(),
|
||||
}
|
||||
)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_start_args_forward_environment_manager_and_openai_form_capability() {
|
||||
let config = Arc::new(build_test_config().await);
|
||||
@@ -2260,45 +2079,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_start_args_use_remote_thread_config_loader_when_configured() {
|
||||
let mut config = build_test_config().await;
|
||||
config.experimental_thread_config_endpoint = Some("not-a-valid-endpoint".to_string());
|
||||
|
||||
let runtime_args = InProcessClientStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: Arc::new(config),
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
strict_config: false,
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
state_db: None,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
config_warnings: Vec::new(),
|
||||
session_source: SessionSource::Exec,
|
||||
enable_codex_api_key_env: false,
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
experimental_api: true,
|
||||
mcp_server_openai_form_elicitation: false,
|
||||
opt_out_notification_methods: Vec::new(),
|
||||
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
}
|
||||
.into_runtime_start_args();
|
||||
|
||||
let err = runtime_args
|
||||
.thread_config_loader
|
||||
.load(Default::default())
|
||||
.await
|
||||
.expect_err("configured remote loader should try to connect");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
codex_config::ThreadConfigLoadErrorCode::RequestFailed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_completes_promptly_without_retained_managers() {
|
||||
let client = start_test_client(SessionSource::Cli).await;
|
||||
@@ -2315,7 +2095,7 @@ mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (command_tx, mut command_rx) = mpsc::channel(1);
|
||||
let (_event_tx, event_rx) = mpsc::channel(1);
|
||||
let (_event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let completed = Arc::new(AtomicBool::new(false));
|
||||
let worker_completed = Arc::clone(&completed);
|
||||
let worker_handle = tokio::spawn(async move {
|
||||
|
||||
+66
-18
@@ -148,12 +148,19 @@ enum RemoteClientCommand {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RemoteServerMetadata {
|
||||
server_version: Option<String>,
|
||||
codex_home: Option<String>,
|
||||
platform_family: Option<String>,
|
||||
platform_os: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RemoteAppServerClient {
|
||||
command_tx: mpsc::Sender<RemoteClientCommand>,
|
||||
event_rx: mpsc::UnboundedReceiver<AppServerEvent>,
|
||||
pending_events: VecDeque<AppServerEvent>,
|
||||
server_version: Option<String>,
|
||||
codex_home: Option<String>,
|
||||
metadata: RemoteServerMetadata,
|
||||
worker_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -162,8 +169,34 @@ pub struct RemoteAppServerRequestHandle {
|
||||
command_tx: mpsc::Sender<RemoteClientCommand>,
|
||||
}
|
||||
|
||||
enum SocketPeerPolicy {
|
||||
ExplicitEndpoint,
|
||||
#[cfg(windows)]
|
||||
NonElevatedCurrentUser,
|
||||
}
|
||||
|
||||
impl RemoteAppServerClient {
|
||||
pub async fn connect(args: RemoteAppServerConnectArgs) -> IoResult<Self> {
|
||||
Self::connect_with_policy(args, SocketPeerPolicy::ExplicitEndpoint).await
|
||||
}
|
||||
|
||||
/// Connects to an implicitly discovered Windows daemon, verifying its peer
|
||||
/// token before the WebSocket handshake or any session requests.
|
||||
#[cfg(windows)]
|
||||
pub async fn connect_local_daemon(args: RemoteAppServerConnectArgs) -> IoResult<Self> {
|
||||
if !matches!(args.endpoint, RemoteAppServerEndpoint::UnixSocket { .. }) {
|
||||
return Err(IoError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"local daemon requires a Unix socket",
|
||||
));
|
||||
}
|
||||
Self::connect_with_policy(args, SocketPeerPolicy::NonElevatedCurrentUser).await
|
||||
}
|
||||
|
||||
async fn connect_with_policy(
|
||||
args: RemoteAppServerConnectArgs,
|
||||
peer_policy: SocketPeerPolicy,
|
||||
) -> IoResult<Self> {
|
||||
let channel_capacity = args.channel_capacity.max(1);
|
||||
let initialize_params = args.initialize_params();
|
||||
match args.endpoint {
|
||||
@@ -177,7 +210,8 @@ impl RemoteAppServerClient {
|
||||
.await
|
||||
}
|
||||
RemoteAppServerEndpoint::UnixSocket { socket_path } => {
|
||||
let (endpoint, stream) = connect_unix_socket_endpoint(socket_path).await?;
|
||||
let (endpoint, stream) =
|
||||
connect_unix_socket_endpoint(socket_path, peer_policy).await?;
|
||||
Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params)
|
||||
.await
|
||||
}
|
||||
@@ -185,11 +219,19 @@ impl RemoteAppServerClient {
|
||||
}
|
||||
|
||||
pub fn server_version(&self) -> Option<&str> {
|
||||
self.server_version.as_deref()
|
||||
self.metadata.server_version.as_deref()
|
||||
}
|
||||
|
||||
pub fn codex_home(&self) -> Option<&str> {
|
||||
self.codex_home.as_deref()
|
||||
self.metadata.codex_home.as_deref()
|
||||
}
|
||||
|
||||
pub fn platform_family(&self) -> Option<&str> {
|
||||
self.metadata.platform_family.as_deref()
|
||||
}
|
||||
|
||||
pub fn platform_os(&self) -> Option<&str> {
|
||||
self.metadata.platform_os.as_deref()
|
||||
}
|
||||
|
||||
async fn connect_with_stream<S>(
|
||||
@@ -202,7 +244,7 @@ impl RemoteAppServerClient {
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let mut stream = stream;
|
||||
let (pending_events, server_version, codex_home) = initialize_remote_connection(
|
||||
let (pending_events, metadata) = initialize_remote_connection(
|
||||
&mut stream,
|
||||
&endpoint,
|
||||
initialize_params,
|
||||
@@ -478,8 +520,7 @@ impl RemoteAppServerClient {
|
||||
command_tx,
|
||||
event_rx,
|
||||
pending_events: pending_events.into(),
|
||||
server_version,
|
||||
codex_home,
|
||||
metadata,
|
||||
worker_handle,
|
||||
})
|
||||
}
|
||||
@@ -604,8 +645,7 @@ impl RemoteAppServerClient {
|
||||
command_tx,
|
||||
event_rx,
|
||||
pending_events: _pending_events,
|
||||
server_version: _server_version,
|
||||
codex_home: _codex_home,
|
||||
metadata: _,
|
||||
worker_handle,
|
||||
} = self;
|
||||
let mut worker_handle = worker_handle;
|
||||
@@ -744,6 +784,7 @@ async fn connect_websocket_endpoint(
|
||||
|
||||
async fn connect_unix_socket_endpoint(
|
||||
socket_path: AbsolutePathBuf,
|
||||
peer_policy: SocketPeerPolicy,
|
||||
) -> IoResult<(String, WebSocketStream<UnixStream>)> {
|
||||
let endpoint = format!("unix://{}", socket_path.display());
|
||||
let request = UDS_WEBSOCKET_HANDSHAKE_URL
|
||||
@@ -767,6 +808,11 @@ async fn connect_unix_socket_endpoint(
|
||||
"failed to connect to remote app server at `{endpoint}`: {err}"
|
||||
))
|
||||
})?;
|
||||
match peer_policy {
|
||||
SocketPeerPolicy::ExplicitEndpoint => {}
|
||||
#[cfg(windows)]
|
||||
SocketPeerPolicy::NonElevatedCurrentUser => stream.ensure_non_elevated_peer()?,
|
||||
}
|
||||
let websocket_config = remote_websocket_config();
|
||||
let stream = timeout(
|
||||
CONNECT_TIMEOUT,
|
||||
@@ -800,14 +846,13 @@ async fn initialize_remote_connection<S>(
|
||||
endpoint: &str,
|
||||
params: InitializeParams,
|
||||
initialize_timeout: Duration,
|
||||
) -> IoResult<(Vec<AppServerEvent>, Option<String>, Option<String>)>
|
||||
) -> IoResult<(Vec<AppServerEvent>, RemoteServerMetadata)>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let initialize_request_id = RequestId::String("initialize".to_string());
|
||||
let mut pending_events = Vec::new();
|
||||
let mut server_version = None;
|
||||
let mut codex_home = None;
|
||||
let mut metadata = RemoteServerMetadata::default();
|
||||
write_jsonrpc_message(
|
||||
stream,
|
||||
JSONRPCMessage::Request(jsonrpc_request_from_client_request(
|
||||
@@ -831,7 +876,7 @@ where
|
||||
})?;
|
||||
match message {
|
||||
JSONRPCMessage::Response(response) if response.id == initialize_request_id => {
|
||||
server_version = response
|
||||
metadata.server_version = response
|
||||
.result
|
||||
.get("userAgent")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -839,12 +884,16 @@ where
|
||||
let (_, rest) = user_agent.split_once('/')?;
|
||||
rest.split_whitespace().next().map(str::to_string)
|
||||
});
|
||||
codex_home = response
|
||||
metadata.codex_home = response
|
||||
.result
|
||||
.get("codexHome")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|codex_home| !codex_home.is_empty())
|
||||
.map(str::to_string);
|
||||
metadata.platform_family = response.result.get("platformFamily")
|
||||
.and_then(serde_json::Value::as_str).map(str::to_string);
|
||||
metadata.platform_os = response.result.get("platformOs")
|
||||
.and_then(serde_json::Value::as_str).map(str::to_string);
|
||||
break Ok(());
|
||||
}
|
||||
JSONRPCMessage::Error(error) if error.id == initialize_request_id => {
|
||||
@@ -937,7 +986,7 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((pending_events, server_version, codex_home))
|
||||
Ok((pending_events, metadata))
|
||||
}
|
||||
|
||||
fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option<AppServerEvent> {
|
||||
@@ -1027,8 +1076,7 @@ mod tests {
|
||||
command_tx,
|
||||
event_rx,
|
||||
pending_events: VecDeque::new(),
|
||||
server_version: None,
|
||||
codex_home: None,
|
||||
metadata: RemoteServerMetadata::default(),
|
||||
worker_handle,
|
||||
};
|
||||
|
||||
|
||||
+3
@@ -38,3 +38,6 @@ tokio-tungstenite = { workspace = true }
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] }
|
||||
|
||||
+34
-12
@@ -10,9 +10,22 @@ machines that should expose app-server with `remote_control` enabled.
|
||||
|
||||
## Platform support
|
||||
|
||||
The current daemon implementation is Unix-only. It uses pidfile-backed
|
||||
daemonization plus Unix process and file-locking primitives, and does not yet
|
||||
support Windows lifecycle management.
|
||||
The daemon supports Linux, macOS, and Windows using platform-specific process
|
||||
and file-locking primitives. Windows startup requires a non-elevated terminal
|
||||
whose host permits detached child processes.
|
||||
|
||||
Windows automatic attachment requires the canonical socket address to fit the
|
||||
108-byte AF_UNIX limit (including its terminator). A short junction alias whose
|
||||
resolved address exceeds that limit falls back to the embedded server. Use a
|
||||
shorter `CODEX_HOME` to share the daemon; discovery does not trust a mutable alias.
|
||||
|
||||
Shared clients use the environment inherited when the daemon started. Opening a
|
||||
new terminal or clearing variables there does not clear the running daemon's
|
||||
environment; per-client environment isolation is not provided.
|
||||
An invocation that sets `CODEX_EXEC_SERVER_URL` skips implicit daemon attachment
|
||||
so its executor selection is preserved. If an implicitly discovered daemon cannot
|
||||
initialize the connection, the TUI starts an embedded server instead. Explicit
|
||||
`--remote` endpoints remain authoritative and report connection failures.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -33,36 +46,45 @@ running app-server version when applicable.
|
||||
|
||||
## Bootstrap flow
|
||||
|
||||
For a new remote machine:
|
||||
For a new Linux or macOS machine:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||
$HOME/.codex/packages/standalone/current/codex app-server daemon bootstrap --remote-control
|
||||
```
|
||||
|
||||
On Windows, use a non-elevated PowerShell terminal whose host allows breakaway:
|
||||
|
||||
```powershell
|
||||
irm https://chatgpt.com/codex/install.ps1 | iex
|
||||
$codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME '.codex' }
|
||||
& "$codexHome\packages\standalone\current\bin\codex.exe" app-server daemon bootstrap --remote-control
|
||||
```
|
||||
|
||||
`bootstrap` requires the standalone managed install. It records the daemon
|
||||
settings under `CODEX_HOME/app-server-daemon/`, starts app-server as a
|
||||
pidfile-backed detached process, and launches a detached updater loop.
|
||||
|
||||
## Installation and update cases
|
||||
|
||||
The daemon assumes Codex is installed through `install.sh` and always launches
|
||||
the standalone managed binary under `CODEX_HOME`.
|
||||
The daemon uses the standalone installer (`install.sh` on Unix, `install.ps1`
|
||||
on Windows) and its managed binary under `CODEX_HOME/packages/standalone/current`:
|
||||
`bin/codex` or `bin/codex.exe`, falling back to the legacy flat layout when present.
|
||||
|
||||
| Situation | What starts | Does this daemon fetch new binaries? | Does a running app-server eventually move to a newer binary on its own? |
|
||||
| --- | --- | --- | --- |
|
||||
| `install.sh` has run, but only `start` is used | `start` uses `CODEX_HOME/packages/standalone/current/codex` | No | No. The managed path is used when starting or restarting, but no updater is installed. |
|
||||
| `install.sh` has run, then `bootstrap` is used | The pidfile backend uses `CODEX_HOME/packages/standalone/current/codex` | Yes. Bootstrap launches a detached updater loop that runs `install.sh` hourly. | Yes, while that updater process is alive and app-server is already running. After a successful fetch, the updater restarts app-server with the refreshed binary and only then replaces its own process image. |
|
||||
| Some other tool updates the managed binary path | The next fresh start or restart uses the updated file at that path | Only if `bootstrap` is active, because the updater still runs `install.sh` on its normal cadence. | Without `bootstrap`, no. With `bootstrap`, the next successful updater pass compares the managed binary contents after `install.sh` runs; if app-server is running and they differ from the updater's current image, it refreshes app-server first and then itself. |
|
||||
| Installer has run; only `start` is used | Managed binary | No | No; explicit restart is required. |
|
||||
| Installer has run; `bootstrap` is used | Managed binary and detached updater | Yes; the platform's installer runs hourly. | Yes; after a successful update, a running app-server restarts with the new binary before the updater replaces itself. |
|
||||
| Another tool updates the managed binary | Next start or restart uses it | Only with `bootstrap`, on its normal cadence. | With `bootstrap`, the next successful installer pass compares binary contents and refreshes a running app-server before the updater. |
|
||||
|
||||
### Standalone installs
|
||||
|
||||
For installs created by `install.sh`:
|
||||
For installs created by either platform's standalone installer:
|
||||
|
||||
- lifecycle commands always use the standalone managed binary path
|
||||
- `bootstrap` is supported
|
||||
- `bootstrap` starts a detached pid-backed updater loop that fetches via
|
||||
`install.sh`
|
||||
the platform's installer
|
||||
- after a successful refresh, if app-server is running and the managed binary
|
||||
contents changed, the updater restarts app-server with that binary first and
|
||||
only then replaces its own process image
|
||||
@@ -77,7 +99,7 @@ other tool updates the managed binary path:
|
||||
- without `bootstrap`, a currently running app-server remains on the old
|
||||
executable image until an explicit `restart`
|
||||
- with `bootstrap`, the detached updater loop notices the changed managed
|
||||
binary on its next successful scheduled pass after running `install.sh`; if
|
||||
binary on its next successful scheduled installer pass; if
|
||||
app-server is running, it refreshes app-server first and then refreshes itself
|
||||
once that replacement starts successfully
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod pid;
|
||||
#[cfg(windows)]
|
||||
pub(crate) mod windows;
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
+91
-134
@@ -1,14 +1,14 @@
|
||||
//! PID reservations serialize detached launches; creation times protect stale-record cleanup.
|
||||
|
||||
use std::io::SeekFrom;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(unix)]
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
use codex_app_server_transport::REMOTE_CONTROL_DISABLED_ENV_VAR;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -26,7 +26,7 @@ const START_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const STDERR_LOG_TAIL_BYTES: u64 = 4096;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
||||
pub(crate) struct PidBackend {
|
||||
codex_bin: PathBuf,
|
||||
pid_file: PathBuf,
|
||||
@@ -68,7 +68,7 @@ enum PidFileState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
||||
enum PidCommandKind {
|
||||
AppServer { remote_control_enabled: bool },
|
||||
UpdateLoop,
|
||||
@@ -115,124 +115,12 @@ impl PidBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
||||
if let Some(parent) = self.pid_file.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create pid directory {}", parent.display()))?;
|
||||
}
|
||||
let reservation_lock = self.acquire_reservation_lock().await?;
|
||||
let _pid_file = loop {
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.pid_file)
|
||||
.await
|
||||
{
|
||||
Ok(pid_file) => break pid_file,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
match self.read_pid_file_state_with_lock_held().await? {
|
||||
PidFileState::Missing => continue,
|
||||
PidFileState::Running(record) => {
|
||||
if self.record_is_active(&record).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
continue;
|
||||
}
|
||||
PidFileState::Starting => {
|
||||
unreachable!("lock holder cannot observe starting")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to reserve pid file {}", self.pid_file.display())
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut command = Command::new(&self.codex_bin);
|
||||
let stderr_log = match self.open_stderr_log().await {
|
||||
Ok(stderr_log) => stderr_log,
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
command
|
||||
.args(self.command_args())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_log.into_std().await));
|
||||
if let Some((key, value)) = self.command_env() {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if libc::setsid() == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err).with_context(|| {
|
||||
format!(
|
||||
"failed to spawn detached app-server process using {}",
|
||||
self.codex_bin.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
};
|
||||
let pid = child
|
||||
.id()
|
||||
.context("spawned app-server process has no pid")?;
|
||||
let record = match read_process_start_time(pid).await {
|
||||
Ok(process_start_time) => PidRecord {
|
||||
pid,
|
||||
process_start_time,
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = self.terminate_process(pid);
|
||||
let mut context =
|
||||
format!("failed to record pid-managed app-server process {pid} startup");
|
||||
super::append_stderr_log_tail_context(&self.pid_file, &mut context).await;
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err).context(context);
|
||||
}
|
||||
};
|
||||
let contents = serde_json::to_vec(&record).context("failed to serialize pid record")?;
|
||||
let temp_pid_file = self.pid_file.with_extension("pid.tmp");
|
||||
if let Err(err) = fs::write(&temp_pid_file, &contents).await {
|
||||
let _ = self.terminate_process(pid);
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to write pid temp file {}", temp_pid_file.display())
|
||||
});
|
||||
}
|
||||
if let Err(err) = fs::rename(&temp_pid_file, &self.pid_file).await {
|
||||
let _ = self.terminate_process(pid);
|
||||
let _ = fs::remove_file(&temp_pid_file).await;
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to publish pid file {}", self.pid_file.display())
|
||||
});
|
||||
}
|
||||
drop(reservation_lock);
|
||||
Ok(Some(pid))
|
||||
self.start_inner(/*replacement*/ None).await
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
||||
bail!("pid-managed app-server startup is unsupported on this platform")
|
||||
}
|
||||
@@ -250,7 +138,21 @@ impl PidBackend {
|
||||
}
|
||||
|
||||
let pid = record.pid;
|
||||
#[cfg(unix)]
|
||||
self.terminate_process(pid)?;
|
||||
#[cfg(windows)]
|
||||
let process = {
|
||||
let Some(process) = super::windows::Process::open(pid)? else {
|
||||
continue;
|
||||
};
|
||||
if process.start_time()? != record.process_start_time {
|
||||
continue;
|
||||
}
|
||||
fs::write(self.pid_file.with_extension("shutdown"), pid.to_string())
|
||||
.await
|
||||
.context("failed to request daemon shutdown")?;
|
||||
process
|
||||
};
|
||||
let started_at = tokio::time::Instant::now();
|
||||
let deadline = tokio::time::Instant::now() + STOP_TIMEOUT;
|
||||
let mut forced = false;
|
||||
@@ -272,7 +174,10 @@ impl PidBackend {
|
||||
break;
|
||||
}
|
||||
if !forced && started_at.elapsed() >= STOP_GRACE_PERIOD {
|
||||
#[cfg(unix)]
|
||||
self.force_terminate_process(pid)?;
|
||||
#[cfg(windows)]
|
||||
process.terminate()?;
|
||||
forced = true;
|
||||
}
|
||||
sleep(STOP_POLL_INTERVAL).await;
|
||||
@@ -392,7 +297,7 @@ impl PidBackend {
|
||||
Ok(reservation_lock)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
async fn open_stderr_log(&self) -> Result<fs::File> {
|
||||
let stderr_log_file = stderr_log_file_for_pid_file(&self.pid_file);
|
||||
fs::OpenOptions::new()
|
||||
@@ -409,7 +314,7 @@ impl PidBackend {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn command_args(&self) -> Vec<&'static str> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer {
|
||||
@@ -422,7 +327,7 @@ impl PidBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn command_env(&self) -> Option<(&'static str, &'static str)> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer {
|
||||
@@ -442,6 +347,7 @@ impl PidBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn force_terminate_process(&self, pid: u32) -> Result<()> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer { .. } => force_terminate_process(pid),
|
||||
@@ -558,17 +464,17 @@ fn force_terminate_process_group(pid: u32) -> Result<()> {
|
||||
Err(err).with_context(|| format!("failed to force terminate pid-managed updater group {pid}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn terminate_process(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed app-server shutdown is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn force_terminate_process(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed app-server shutdown is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn force_terminate_process_group(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed updater shutdown is unsupported on this platform")
|
||||
}
|
||||
@@ -586,13 +492,13 @@ async fn process_matches_record(record: &PidRecord) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
async fn process_matches_record(_record: &PidRecord) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
||||
enum EmptyPidReservation {
|
||||
Active,
|
||||
Stale,
|
||||
@@ -615,12 +521,12 @@ fn try_lock_file(file: &fs::File) -> Result<bool> {
|
||||
Err(err).context("failed to lock pid reservation")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn try_lock_file(_file: &fs::File) -> Result<bool> {
|
||||
bail!("pid-managed app-server startup is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
async fn reservation_lock_is_active(path: &Path) -> Result<bool> {
|
||||
let file = match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
@@ -641,12 +547,12 @@ async fn reservation_lock_is_active(path: &Path) -> Result<bool> {
|
||||
Ok(!try_lock_file(&file)?)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
async fn reservation_lock_is_active(_path: &Path) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
async fn inspect_empty_pid_reservation(
|
||||
pid_path: &Path,
|
||||
lock_path: &Path,
|
||||
@@ -689,7 +595,7 @@ async fn inspect_empty_pid_reservation(
|
||||
Ok(EmptyPidReservation::Record(record))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
async fn inspect_empty_pid_reservation(
|
||||
_pid_path: &Path,
|
||||
_lock_path: &Path,
|
||||
@@ -717,6 +623,57 @@ async fn read_process_start_time(pid: u32) -> Result<String> {
|
||||
Ok(start_time.to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(all(test, any(unix, windows)))]
|
||||
#[path = "pid_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(windows)]
|
||||
use super::windows::try_lock_file;
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn read_process_start_time(pid: u32) -> Result<String> {
|
||||
super::windows::Process::open(pid)?
|
||||
.context("daemon process exited during startup")?
|
||||
.start_time()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn process_matches_record(record: &PidRecord) -> Result<bool> {
|
||||
let process = match super::windows::Process::open(record.pid) {
|
||||
Ok(process) => process,
|
||||
// A managed daemon is queryable by its launching user. A stale PID may
|
||||
// have been reused by a protected process; never try to terminate it.
|
||||
Err(err)
|
||||
if err.downcast_ref::<std::io::Error>().is_some_and(|err| {
|
||||
err.raw_os_error()
|
||||
== Some(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32)
|
||||
}) =>
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let Some(process) = process else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(process.is_running()? && process.start_time()? == record.process_start_time)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn force_terminate_process(pid: u32) -> Result<()> {
|
||||
if let Some(process) = super::windows::Process::open(pid)? {
|
||||
process.terminate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
use force_terminate_process as terminate_process;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "pid_windows.rs"]
|
||||
mod windows;
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[path = "pid_start.rs"]
|
||||
mod start;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Detached process launch and PID publication. Hold the reservation lock until
|
||||
//! the record is published, and on Windows until an updater acknowledges startup.
|
||||
|
||||
use super::PidBackend;
|
||||
#[cfg(windows)]
|
||||
use super::PidCommandKind;
|
||||
use super::PidFileState;
|
||||
use super::PidRecord;
|
||||
use super::read_process_start_time;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use std::process::Stdio;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
|
||||
impl PidBackend {
|
||||
pub(super) async fn start_inner(&self, replacement: Option<PidRecord>) -> Result<Option<u32>> {
|
||||
#[cfg(windows)]
|
||||
crate::backend::windows::ensure_not_elevated()?;
|
||||
if let Some(parent) = self.pid_file.parent() {
|
||||
codex_uds::prepare_private_socket_directory(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create pid directory {}", parent.display()))?;
|
||||
}
|
||||
let reservation_lock = self.acquire_reservation_lock().await?;
|
||||
loop {
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.pid_file)
|
||||
.await
|
||||
{
|
||||
Ok(pid_file) => {
|
||||
drop(pid_file);
|
||||
break;
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
match self.read_pid_file_state_with_lock_held().await? {
|
||||
PidFileState::Missing => continue,
|
||||
PidFileState::Running(record) => {
|
||||
if replacement.as_ref() == Some(&record) {
|
||||
break;
|
||||
}
|
||||
if replacement.is_some() {
|
||||
if self.record_is_active(&record).await? {
|
||||
bail!("updater ownership changed before handoff");
|
||||
}
|
||||
// A failed rollback may leave the dead successor's record.
|
||||
break;
|
||||
}
|
||||
if self.record_is_active(&record).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
continue;
|
||||
}
|
||||
PidFileState::Starting => {
|
||||
unreachable!("lock holder cannot observe starting")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to reserve pid file {}", self.pid_file.display())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pin the Windows image path across installer junction retargeting.
|
||||
#[cfg(windows)]
|
||||
let codex_bin = fs::canonicalize(&self.codex_bin)
|
||||
.await
|
||||
.unwrap_or_else(|_| self.codex_bin.clone());
|
||||
#[cfg(not(windows))]
|
||||
let codex_bin = &self.codex_bin;
|
||||
let mut command = Command::new(codex_bin);
|
||||
let stderr_log = match self.open_stderr_log().await {
|
||||
Ok(stderr_log) => stderr_log,
|
||||
Err(err) => {
|
||||
if replacement.is_none() {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
command
|
||||
.args(self.command_args())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_log.into_std().await));
|
||||
if let Some((key, value)) = self.command_env() {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if libc::setsid() == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB;
|
||||
use windows_sys::Win32::System::Threading::DETACHED_PROCESS;
|
||||
// Preserve process-scoped paths before changing cwd; CA names match CUSTOM_CA_ENV_KEYS.
|
||||
for name in [
|
||||
"CODEX_HOME",
|
||||
"CODEX_SQLITE_HOME",
|
||||
"CODEX_CA_CERTIFICATE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"GIT_SSL_CAINFO",
|
||||
"CARGO_HTTP_CAINFO",
|
||||
"PIP_CERT",
|
||||
"BUNDLE_SSL_CA_CERT",
|
||||
"npm_config_cafile",
|
||||
"AWS_CONFIG_FILE",
|
||||
"AWS_SHARED_CREDENTIALS_FILE",
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
||||
"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
|
||||
] {
|
||||
let Some(mut value) = std::env::var_os(name) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(name, "CODEX_SQLITE_HOME" | "npm_config_cafile") {
|
||||
value = value.to_str().unwrap_or_default().trim().into();
|
||||
}
|
||||
// These consumers expand `~` independently of the working directory.
|
||||
let expands_home = if name == "npm_config_cafile" {
|
||||
value
|
||||
.to_str()
|
||||
.is_some_and(|path| path.starts_with("~/") || path.starts_with("~\\"))
|
||||
} else {
|
||||
matches!(
|
||||
name,
|
||||
"CODEX_SQLITE_HOME" | "AWS_CONFIG_FILE" | "AWS_SHARED_CREDENTIALS_FILE"
|
||||
) && std::path::Path::new(&value).starts_with("~")
|
||||
};
|
||||
if value.is_empty() || expands_home {
|
||||
continue;
|
||||
}
|
||||
command.env(name, std::path::absolute(value)?);
|
||||
}
|
||||
if let Some(value) = std::env::var_os("SSL_CERT_DIR") {
|
||||
let paths = std::env::split_paths(&value)
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.map(std::path::absolute)
|
||||
.collect::<std::io::Result<Vec<_>>>()?;
|
||||
command.env("SSL_CERT_DIR", std::env::join_paths(paths)?);
|
||||
}
|
||||
// A Windows process pins its working directory for its lifetime.
|
||||
// Keep both managed children out of the launching project's directory.
|
||||
command.current_dir(
|
||||
self.pid_file
|
||||
.parent()
|
||||
.context("daemon pid path has no parent")?,
|
||||
);
|
||||
// Never retry inside the parent's Job Object: that would report a
|
||||
// successful launch that dies when the terminal/SSH session closes.
|
||||
command.creation_flags(DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB);
|
||||
if matches!(self.command_kind, PidCommandKind::UpdateLoop) {
|
||||
match fs::remove_file(self.pid_file.with_extension("ready")).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err).context("failed to clear updater readiness"),
|
||||
}
|
||||
}
|
||||
let shutdown_file = self.pid_file.with_extension("shutdown");
|
||||
match fs::remove_file(&shutdown_file).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err).context("failed to clear daemon shutdown request"),
|
||||
}
|
||||
command.env(
|
||||
codex_app_server_transport::DAEMON_SHUTDOWN_FILE_ENV,
|
||||
shutdown_file,
|
||||
);
|
||||
}
|
||||
|
||||
let child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
if replacement.is_none() {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
}
|
||||
return Err(err).with_context(|| {
|
||||
let job_hint = if cfg!(windows) {
|
||||
" (the Windows host job must allow breakaway)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"failed to spawn detached app-server process using {}{job_hint}",
|
||||
self.codex_bin.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
};
|
||||
let pid = child
|
||||
.id()
|
||||
.context("spawned app-server process has no pid")?;
|
||||
let record = match async {
|
||||
#[cfg(windows)]
|
||||
super::super::windows::Process::open(pid)?
|
||||
.context("daemon exited during launch")?
|
||||
.ensure_detached()?;
|
||||
read_process_start_time(pid).await
|
||||
}
|
||||
.await
|
||||
{
|
||||
Ok(process_start_time) => PidRecord {
|
||||
pid,
|
||||
process_start_time,
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = self.terminate_process(pid);
|
||||
let mut context =
|
||||
format!("failed to record pid-managed app-server process {pid} startup");
|
||||
super::super::append_stderr_log_tail_context(&self.pid_file, &mut context).await;
|
||||
if replacement.is_none() {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
}
|
||||
return Err(err).context(context);
|
||||
}
|
||||
};
|
||||
let contents = serde_json::to_vec(&record).context("failed to serialize pid record")?;
|
||||
let temp_pid_file = self.pid_file.with_extension("pid.tmp");
|
||||
if let Err(err) = fs::write(&temp_pid_file, &contents).await {
|
||||
let _ = self.terminate_process(pid);
|
||||
if replacement.is_none() {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
}
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to write pid temp file {}", temp_pid_file.display())
|
||||
});
|
||||
}
|
||||
if let Err(err) = fs::rename(&temp_pid_file, &self.pid_file).await {
|
||||
let _ = self.terminate_process(pid);
|
||||
let _ = fs::remove_file(&temp_pid_file).await;
|
||||
if replacement.is_none() {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
}
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to publish pid file {}", self.pid_file.display())
|
||||
});
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if matches!(self.command_kind, PidCommandKind::UpdateLoop) {
|
||||
self.finish_updater_start(&record, replacement.as_ref())
|
||||
.await?;
|
||||
}
|
||||
drop(reservation_lock);
|
||||
Ok(Some(pid))
|
||||
}
|
||||
}
|
||||
+272
-1
@@ -1,3 +1,4 @@
|
||||
#[cfg(unix)]
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -11,11 +12,33 @@ use super::PidCommandKind;
|
||||
use super::PidFileState;
|
||||
use super::PidLogTail;
|
||||
use super::PidRecord;
|
||||
#[cfg(unix)]
|
||||
use super::read_process_start_time;
|
||||
use super::read_stderr_log_tail;
|
||||
use super::stderr_log_file_for_pid_file;
|
||||
use super::try_lock_file;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_elevated_test_process() -> anyhow::Result<bool> {
|
||||
let output = std::process::Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
|
||||
])
|
||||
.output()?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"administrator membership query failed"
|
||||
);
|
||||
match String::from_utf8(output.stdout)?.trim() {
|
||||
"True" => Ok(true),
|
||||
"False" => Ok(false),
|
||||
other => anyhow::bail!("unexpected administrator membership: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_empty_pid_file_is_treated_as_active_reservation() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -99,7 +122,11 @@ async fn stop_waits_for_live_reservation_to_resolve() {
|
||||
#[tokio::test]
|
||||
async fn start_retries_stale_empty_pid_file_under_its_own_lock() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pid_file = temp_dir.path().join("app-server.pid");
|
||||
let state_dir = temp_dir.path().join("state");
|
||||
codex_uds::prepare_private_socket_directory(&state_dir)
|
||||
.await
|
||||
.expect("private state directory");
|
||||
let pid_file = state_dir.join("app-server.pid");
|
||||
tokio::fs::write(&pid_file, "")
|
||||
.await
|
||||
.expect("write pid file");
|
||||
@@ -110,6 +137,16 @@ async fn start_retries_stale_empty_pid_file_under_its_own_lock() {
|
||||
);
|
||||
|
||||
let err = backend.start().await.expect_err("start");
|
||||
#[cfg(windows)]
|
||||
if is_elevated_test_process().expect("query administrator membership") {
|
||||
assert!(err.to_string().contains("non-elevated terminal"));
|
||||
assert_eq!(
|
||||
tokio::fs::read(&backend.pid_file).await.unwrap(),
|
||||
Vec::<u8>::new()
|
||||
);
|
||||
assert!(!backend.lock_file.exists());
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
err.to_string()
|
||||
.starts_with("failed to spawn detached app-server process using ")
|
||||
@@ -149,6 +186,7 @@ async fn stale_record_cleanup_preserves_replacement_record() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_reaps_untracked_app_server_child() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -255,3 +293,236 @@ async fn read_stderr_log_tail_returns_recent_complete_lines() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn stale_creation_time_never_stops_reused_pid() {
|
||||
let temp = TempDir::new().expect("temp");
|
||||
let backend = PidBackend::new(
|
||||
temp.path().join("codex.exe"),
|
||||
temp.path().join("server.pid"),
|
||||
/*remote_control_enabled*/ false,
|
||||
);
|
||||
let record = PidRecord {
|
||||
pid: std::process::id(),
|
||||
process_start_time: "stale".into(),
|
||||
};
|
||||
tokio::fs::write(&backend.pid_file, serde_json::to_vec(&record).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
backend.stop().await.expect("stale record cleanup");
|
||||
assert!(!backend.pid_file.exists());
|
||||
assert!(!backend.pid_file.with_extension("shutdown").exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn failed_updater_handoff_preserves_predecessor_record() {
|
||||
let elevated = is_elevated_test_process().expect("query administrator membership");
|
||||
let temp = TempDir::new().expect("temp");
|
||||
let state_dir = temp.path().join("state");
|
||||
codex_uds::prepare_private_socket_directory(&state_dir)
|
||||
.await
|
||||
.expect("private state directory");
|
||||
let backend = PidBackend::new_update_loop(
|
||||
temp.path().join("missing-codex.exe"),
|
||||
state_dir.join("updater.pid"),
|
||||
);
|
||||
let record = PidRecord {
|
||||
pid: std::process::id(),
|
||||
process_start_time: super::read_process_start_time(std::process::id())
|
||||
.await
|
||||
.unwrap(),
|
||||
};
|
||||
for record in [
|
||||
record.clone(),
|
||||
PidRecord {
|
||||
process_start_time: "stale".into(),
|
||||
..record
|
||||
},
|
||||
] {
|
||||
tokio::fs::write(&backend.pid_file, serde_json::to_vec(&record).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let error = backend.replace_current_updater().await.unwrap_err();
|
||||
if elevated {
|
||||
assert!(error.to_string().contains("non-elevated terminal"));
|
||||
} else {
|
||||
// Windows may reject breakaway before reporting the missing executable.
|
||||
// Both must reach process I/O rather than rejecting stale ownership.
|
||||
assert!(error.root_cause().is::<std::io::Error>(), "{error:#}");
|
||||
}
|
||||
assert_eq!(
|
||||
backend.read_pid_file_state().await.unwrap(),
|
||||
PidFileState::Running(record)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn updater_readiness_and_post_publication_failure_preserve_ownership() {
|
||||
use futures::FutureExt;
|
||||
use windows_sys::Win32::Security::ImpersonateAnonymousToken;
|
||||
use windows_sys::Win32::Security::RevertToSelf;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentThread;
|
||||
|
||||
let temp = TempDir::new().expect("temp");
|
||||
let backend = PidBackend::new_update_loop(
|
||||
temp.path().join("codex.exe"),
|
||||
temp.path().join("updater.pid"),
|
||||
);
|
||||
let _lock = backend
|
||||
.acquire_reservation_lock()
|
||||
.await
|
||||
.expect("reservation lock");
|
||||
let mut child = tokio::process::Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Start-Sleep 60",
|
||||
])
|
||||
.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW)
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.expect("successor fixture");
|
||||
let pid = child.id().expect("pid");
|
||||
let successor = PidRecord {
|
||||
pid,
|
||||
process_start_time: super::read_process_start_time(pid)
|
||||
.await
|
||||
.expect("creation time"),
|
||||
};
|
||||
let predecessor = PidRecord {
|
||||
pid: std::process::id(),
|
||||
process_start_time: super::read_process_start_time(std::process::id())
|
||||
.await
|
||||
.expect("creation time"),
|
||||
};
|
||||
tokio::fs::write(&backend.pid_file, serde_json::to_vec(&successor).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let ready = backend.pid_file.with_extension("ready");
|
||||
tokio::fs::write(&ready, b"").await.unwrap();
|
||||
backend
|
||||
.finish_updater_start(&successor, Some(&predecessor))
|
||||
.await
|
||||
.expect("ready successor");
|
||||
assert!(!ready.exists());
|
||||
assert_eq!(
|
||||
backend.read_pid_file_state().await.unwrap(),
|
||||
PidFileState::Running(successor.clone())
|
||||
);
|
||||
|
||||
// If cleanup cannot even query the successor, leave its ownership intact.
|
||||
std::thread::scope(|scope| {
|
||||
scope
|
||||
.spawn(|| {
|
||||
assert_ne!(unsafe { ImpersonateAnonymousToken(GetCurrentThread()) }, 0);
|
||||
let result = backend
|
||||
.finish_updater_start(&successor, Some(&predecessor))
|
||||
.now_or_never();
|
||||
let reverted = unsafe { RevertToSelf() };
|
||||
assert_ne!(reverted, 0);
|
||||
let error = result
|
||||
.expect("denied cleanup must not suspend")
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.unwrap()
|
||||
.raw_os_error(),
|
||||
Some(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32)
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.expect("anonymous cleanup check");
|
||||
});
|
||||
assert_eq!(
|
||||
backend.read_pid_file_state().await.unwrap(),
|
||||
PidFileState::Running(successor.clone())
|
||||
);
|
||||
|
||||
let reused_pid = PidRecord {
|
||||
process_start_time: "stale".into(),
|
||||
..successor.clone()
|
||||
};
|
||||
assert!(
|
||||
backend
|
||||
.finish_updater_start(&reused_pid, Some(&predecessor))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
backend.read_pid_file_state().await.unwrap(),
|
||||
PidFileState::Running(predecessor.clone())
|
||||
);
|
||||
assert!(
|
||||
child.try_wait().unwrap().is_none(),
|
||||
"PID reuse must not terminate another process"
|
||||
);
|
||||
tokio::fs::write(&backend.pid_file, serde_json::to_vec(&successor).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
// An invalid readiness path triggers cleanup while the successor is alive.
|
||||
tokio::fs::create_dir(&ready).await.unwrap();
|
||||
assert!(
|
||||
backend
|
||||
.finish_updater_start(&successor, Some(&predecessor))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
child.try_wait().unwrap().is_some(),
|
||||
"successor must exit before rollback"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.read_pid_file_state().await.unwrap(),
|
||||
PidFileState::Running(predecessor)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn inaccessible_reused_pid_is_stale_without_hiding_process_open_errors() {
|
||||
use futures::FutureExt;
|
||||
use windows_sys::Win32::Security::ImpersonateAnonymousToken;
|
||||
use windows_sys::Win32::Security::RevertToSelf;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentThread;
|
||||
|
||||
// Isolate impersonation from Tokio workers and other tests. Query our own
|
||||
// process anonymously instead of assuming a system PID is inaccessible.
|
||||
std::thread::spawn(|| {
|
||||
let record = PidRecord {
|
||||
pid: std::process::id(),
|
||||
process_start_time: "stale".into(),
|
||||
};
|
||||
assert!(
|
||||
crate::backend::windows::Process::open(record.pid)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert_ne!(unsafe { ImpersonateAnonymousToken(GetCurrentThread()) }, 0);
|
||||
let opened = crate::backend::windows::Process::open(record.pid);
|
||||
// Windows identity checks are synchronous; never suspend while impersonating.
|
||||
let matches = super::process_matches_record(&record).now_or_never();
|
||||
let reverted = unsafe { RevertToSelf() };
|
||||
assert_ne!(reverted, 0);
|
||||
|
||||
let error = match opened {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("anonymous caller unexpectedly queried the test process"),
|
||||
};
|
||||
assert_eq!(
|
||||
error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.unwrap()
|
||||
.raw_os_error(),
|
||||
Some(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32),
|
||||
);
|
||||
assert!(!matches.expect("identity check must not suspend").unwrap());
|
||||
})
|
||||
.join()
|
||||
.expect("anonymous identity check");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Windows updater publication handshake. The predecessor holds the operation
|
||||
//! and reservation locks until its successor initializes and acknowledges readiness.
|
||||
|
||||
use super::super::windows::Process;
|
||||
use super::PidBackend;
|
||||
use super::PidFileState;
|
||||
use super::PidRecord;
|
||||
use super::START_TIMEOUT;
|
||||
use super::STOP_POLL_INTERVAL;
|
||||
use super::process_matches_record;
|
||||
use super::read_process_start_time;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use tokio::fs;
|
||||
use tokio::time::sleep;
|
||||
|
||||
impl PidBackend {
|
||||
pub(crate) async fn wait_for_ownership(&self) -> Result<()> {
|
||||
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
||||
loop {
|
||||
if let PidFileState::Running(record) = self.read_pid_file_state().await?
|
||||
&& record.pid == std::process::id()
|
||||
&& self.record_is_active(&record).await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
bail!("updater was not published by its launcher");
|
||||
}
|
||||
sleep(STOP_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_ready(&self) -> Result<()> {
|
||||
fs::write(self.pid_file.with_extension("ready"), b"")
|
||||
.await
|
||||
.context("failed to acknowledge updater startup")
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_current_updater(&self) -> Result<()> {
|
||||
let record = PidRecord {
|
||||
pid: std::process::id(),
|
||||
process_start_time: read_process_start_time(std::process::id()).await?,
|
||||
};
|
||||
self.start_inner(Some(record)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn finish_updater_start(
|
||||
&self,
|
||||
record: &PidRecord,
|
||||
replacement: Option<&PidRecord>,
|
||||
) -> Result<()> {
|
||||
let temp_pid_file = self.pid_file.with_extension("pid.tmp");
|
||||
let ready = self.pid_file.with_extension("ready");
|
||||
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
||||
let startup = async {
|
||||
loop {
|
||||
if !process_matches_record(record).await? {
|
||||
bail!("updater exited before becoming ready");
|
||||
}
|
||||
match fs::remove_file(&ready).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
bail!("updater did not become ready");
|
||||
}
|
||||
sleep(STOP_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
.await;
|
||||
if let Err(err) = startup {
|
||||
if let Some(process) = Process::open(record.pid)?
|
||||
&& process.start_time()? == record.process_start_time
|
||||
{
|
||||
process.terminate()?;
|
||||
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
||||
while process.is_running()? {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
bail!("failed updater did not exit; ownership was not restored");
|
||||
}
|
||||
sleep(STOP_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
if let Some(previous) = replacement {
|
||||
fs::write(&temp_pid_file, serde_json::to_vec(previous)?).await?;
|
||||
fs::rename(&temp_pid_file, &self.pid_file).await?;
|
||||
} else {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Windows process identity and file locks. Keep a process handle across shutdown
|
||||
//! so PID reuse can never redirect forced termination to a different process.
|
||||
//! Managed servers must not elevate ordinary clients sharing the account's socket.
|
||||
|
||||
use std::io;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::os::windows::io::FromRawHandle;
|
||||
use std::os::windows::io::OwnedHandle;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER;
|
||||
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
|
||||
use windows_sys::Win32::Foundation::FILETIME;
|
||||
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
|
||||
use windows_sys::Win32::Foundation::WAIT_TIMEOUT;
|
||||
use windows_sys::Win32::Security::GetTokenInformation;
|
||||
use windows_sys::Win32::Security::TOKEN_ELEVATION;
|
||||
use windows_sys::Win32::Security::TOKEN_QUERY;
|
||||
use windows_sys::Win32::Security::TokenElevation;
|
||||
use windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK;
|
||||
use windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY;
|
||||
use windows_sys::Win32::Storage::FileSystem::LockFileEx;
|
||||
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
|
||||
use windows_sys::Win32::System::JobObjects::CreateJobObjectW;
|
||||
use windows_sys::Win32::System::JobObjects::IsProcessInJob;
|
||||
use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
||||
use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
use windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION;
|
||||
use windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation;
|
||||
use windows_sys::Win32::System::JobObjects::SetInformationJobObject;
|
||||
use windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB;
|
||||
use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
|
||||
use windows_sys::Win32::System::Threading::DETACHED_PROCESS;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcess;
|
||||
use windows_sys::Win32::System::Threading::GetProcessId;
|
||||
use windows_sys::Win32::System::Threading::GetProcessTimes;
|
||||
use windows_sys::Win32::System::Threading::OpenProcess;
|
||||
use windows_sys::Win32::System::Threading::OpenProcessToken;
|
||||
use windows_sys::Win32::System::Threading::PROCESS_ACCESS_RIGHTS;
|
||||
use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
|
||||
use windows_sys::Win32::System::Threading::PROCESS_SYNCHRONIZE;
|
||||
use windows_sys::Win32::System::Threading::PROCESS_TERMINATE;
|
||||
use windows_sys::Win32::System::Threading::TerminateProcess;
|
||||
use windows_sys::Win32::System::Threading::WaitForSingleObject;
|
||||
|
||||
pub(crate) fn ensure_not_elevated() -> Result<()> {
|
||||
let mut token = 0;
|
||||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
||||
return Err(io::Error::last_os_error()).context("failed to query daemon launcher token");
|
||||
}
|
||||
let token = unsafe { OwnedHandle::from_raw_handle(token as _) };
|
||||
let mut elevation: TOKEN_ELEVATION = unsafe { std::mem::zeroed() };
|
||||
let mut returned = 0;
|
||||
if unsafe {
|
||||
GetTokenInformation(
|
||||
token.as_raw_handle() as _,
|
||||
TokenElevation,
|
||||
(&mut elevation as *mut TOKEN_ELEVATION).cast(),
|
||||
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
|
||||
&mut returned,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error())
|
||||
.context("failed to query daemon launcher elevation");
|
||||
}
|
||||
anyhow::ensure!(
|
||||
elevation.TokenIsElevated == 0,
|
||||
"start the Windows daemon from a non-elevated terminal; shared clients must not inherit administrator privileges"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Probe the actual child association: escaping an inner job can leave an outer
|
||||
// job attached. Suspend the image so no application code runs before cleanup.
|
||||
pub(crate) fn ensure_detached_launch(executable: &Path) -> Result<()> {
|
||||
let mut child = Command::new(executable)
|
||||
.creation_flags(CREATE_SUSPENDED | DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.context("cannot launch detached daemon; existing daemon was not stopped")?;
|
||||
let mut in_job = 0;
|
||||
let result = if unsafe {
|
||||
IsProcessInJob(
|
||||
child.as_raw_handle() as _,
|
||||
/*jobhandle*/ 0,
|
||||
&mut in_job,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
Err(io::Error::last_os_error()).context("failed to verify daemon launch capability")
|
||||
} else if in_job != 0 {
|
||||
Err(anyhow::anyhow!(
|
||||
"host Job Object prevents daemon detachment; start from a host that allows breakaway"
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
child
|
||||
.kill()
|
||||
.context("failed to terminate suspended launch probe")?;
|
||||
child
|
||||
.wait()
|
||||
.context("failed to reap suspended launch probe")?;
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) struct Process(OwnedHandle);
|
||||
|
||||
impl Process {
|
||||
pub(super) fn open(pid: u32) -> Result<Option<Self>> {
|
||||
Self::open_with_access(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE)
|
||||
}
|
||||
|
||||
fn open_with_access(pid: u32, access: PROCESS_ACCESS_RIGHTS) -> Result<Option<Self>> {
|
||||
let handle = unsafe {
|
||||
OpenProcess(access, /*binherithandle*/ 0, pid)
|
||||
};
|
||||
if handle == 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
return if err.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(err).context("failed to open daemon process")
|
||||
};
|
||||
}
|
||||
Ok(Some(Self(unsafe {
|
||||
OwnedHandle::from_raw_handle(handle as _)
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) fn start_time(&self) -> Result<String> {
|
||||
let mut created: FILETIME = unsafe { std::mem::zeroed() };
|
||||
let mut exited = created;
|
||||
let mut kernel = created;
|
||||
let mut user = created;
|
||||
if unsafe {
|
||||
GetProcessTimes(
|
||||
self.0.as_raw_handle() as _,
|
||||
&mut created,
|
||||
&mut exited,
|
||||
&mut kernel,
|
||||
&mut user,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error()).context("failed to query daemon creation time");
|
||||
}
|
||||
Ok(
|
||||
((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn is_running(&self) -> Result<bool> {
|
||||
match unsafe {
|
||||
WaitForSingleObject(self.0.as_raw_handle() as _, /*dwmilliseconds*/ 0)
|
||||
} {
|
||||
WAIT_TIMEOUT => Ok(true),
|
||||
WAIT_OBJECT_0 => Ok(false),
|
||||
_ => Err(io::Error::last_os_error()).context("failed to wait for daemon process"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ensure_detached(&self) -> Result<()> {
|
||||
let mut in_job = 0;
|
||||
if unsafe {
|
||||
IsProcessInJob(
|
||||
self.0.as_raw_handle() as _,
|
||||
/*jobhandle*/ 0,
|
||||
&mut in_job,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
let error = io::Error::last_os_error();
|
||||
self.terminate()?;
|
||||
return Err(error).context("failed to verify daemon detachment");
|
||||
}
|
||||
if in_job != 0 {
|
||||
self.terminate()?;
|
||||
anyhow::bail!(
|
||||
"host Job Object prevents daemon detachment; start from a host that allows breakaway"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn terminate(&self) -> Result<()> {
|
||||
if !self.is_running()? {
|
||||
return Ok(());
|
||||
}
|
||||
let pid = unsafe { GetProcessId(self.0.as_raw_handle() as _) };
|
||||
let Some(target) = Self::open_with_access(
|
||||
pid,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE | PROCESS_TERMINATE,
|
||||
)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
// Keep the original identity handle alive and validate the handle that
|
||||
// will actually be terminated, rather than trusting a second PID lookup.
|
||||
if target.start_time()? != self.start_time()? || !target.is_running()? {
|
||||
return Ok(());
|
||||
}
|
||||
if unsafe {
|
||||
TerminateProcess(target.0.as_raw_handle() as _, /*uexitcode*/ 1)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error()).context("failed to terminate daemon process");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn try_lock_file(file: &tokio::fs::File) -> Result<bool> {
|
||||
let mut overlapped = unsafe { std::mem::zeroed() };
|
||||
if unsafe {
|
||||
LockFileEx(
|
||||
file.as_raw_handle() as _,
|
||||
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
|
||||
/*dwreserved*/ 0,
|
||||
/*nnumberofbytestolocklow*/ 1,
|
||||
/*nnumberofbytestolockhigh*/ 0,
|
||||
&mut overlapped,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(err).context("failed to lock daemon state")
|
||||
}
|
||||
|
||||
// Keep installer descendants bounded by the updater's lifetime, while allowing
|
||||
// app-server launches and successor updaters to break away from this job.
|
||||
pub(crate) fn updater_job() -> Result<OwnedHandle> {
|
||||
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
||||
if job == 0 {
|
||||
return Err(io::Error::last_os_error()).context("failed to create updater job");
|
||||
}
|
||||
let owned = unsafe { OwnedHandle::from_raw_handle(job as _) };
|
||||
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
|
||||
limits.BasicLimitInformation.LimitFlags =
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
||||
if unsafe {
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
(&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
|
||||
std::mem::size_of_val(&limits) as u32,
|
||||
)
|
||||
} == 0
|
||||
|| unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) } == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error())
|
||||
.context("failed to contain updater installer processes");
|
||||
}
|
||||
Ok(owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "windows_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
use super::Process;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED;
|
||||
use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
|
||||
use windows_sys::Win32::System::Threading::TerminateProcess;
|
||||
|
||||
#[test]
|
||||
fn detached_launch_preflight_rejects_restrictive_job() {
|
||||
const CHILD: &str = "CODEX_TEST_RESTRICTIVE_LAUNCH_JOB";
|
||||
let executable = std::env::current_exe().expect("test executable");
|
||||
if std::env::var_os(CHILD).is_some() {
|
||||
let job = unsafe { super::CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
||||
assert_ne!(job, 0);
|
||||
let job = unsafe {
|
||||
<std::os::windows::io::OwnedHandle as std::os::windows::io::FromRawHandle>::from_raw_handle(job as _)
|
||||
};
|
||||
assert_ne!(
|
||||
unsafe {
|
||||
super::AssignProcessToJobObject(
|
||||
job.as_raw_handle() as _,
|
||||
super::GetCurrentProcess(),
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
// A new job does not permit breakaway. Reject before any lifecycle mutation.
|
||||
assert!(super::ensure_detached_launch(&executable).is_err());
|
||||
return;
|
||||
}
|
||||
let output = std::process::Command::new(executable)
|
||||
.args([
|
||||
"--exact",
|
||||
"backend::windows::tests::detached_launch_preflight_rejects_restrictive_job",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.expect("isolated job test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_queries_do_not_require_termination_access() {
|
||||
let mut child = tokio::process::Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Start-Sleep 60",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.expect("child");
|
||||
let process = Process::open(child.id().expect("pid"))
|
||||
.expect("query handle")
|
||||
.expect("live process");
|
||||
assert!(!process.start_time().expect("creation time").is_empty());
|
||||
assert!(process.is_running().expect("liveness"));
|
||||
// Check the rights on the actual query handle, independent of privileges
|
||||
// that could let the caller reopen the process with termination access.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
TerminateProcess(process.0.as_raw_handle() as _, /*uexitcode*/ 1)
|
||||
},
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
Some(ERROR_ACCESS_DENIED as i32)
|
||||
);
|
||||
assert!(
|
||||
process
|
||||
.is_running()
|
||||
.expect("query must not terminate child")
|
||||
);
|
||||
child
|
||||
.kill()
|
||||
.await
|
||||
.expect("cleanup through original spawn handle");
|
||||
}
|
||||
+87
-23
@@ -1,4 +1,8 @@
|
||||
//! Managed app-server lifecycle, serialized across CLI invocations and the updater.
|
||||
|
||||
mod backend;
|
||||
#[cfg(windows)]
|
||||
use backend::windows::try_lock_file;
|
||||
mod client;
|
||||
mod managed_install;
|
||||
mod remote_control_client;
|
||||
@@ -19,7 +23,7 @@ use codex_app_server_protocol::RemoteControlPairingStartResponse;
|
||||
use codex_app_server_transport::app_server_control_socket_path;
|
||||
use codex_utils_home_dir::find_codex_home;
|
||||
use managed_install::managed_codex_bin;
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
use managed_install::managed_codex_version;
|
||||
use serde::Serialize;
|
||||
use settings::DaemonSettings;
|
||||
@@ -156,7 +160,7 @@ pub struct RemoteControlOutput {
|
||||
pub app_server_version: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RestartIfRunningOutcome {
|
||||
Busy,
|
||||
@@ -166,21 +170,21 @@ pub(crate) enum RestartIfRunningOutcome {
|
||||
Restarted,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RestartMode {
|
||||
IfVersionChanged,
|
||||
Always,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UpdaterRefreshMode {
|
||||
None,
|
||||
ReexecIfManagedBinaryChanged,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RestartDecision {
|
||||
NotReady,
|
||||
@@ -190,16 +194,24 @@ enum RestartDecision {
|
||||
|
||||
pub async fn run(command: LifecycleCommand) -> Result<LifecycleOutput> {
|
||||
ensure_supported_platform()?;
|
||||
#[cfg(windows)]
|
||||
if matches!(command, LifecycleCommand::Start | LifecycleCommand::Restart) {
|
||||
backend::windows::ensure_not_elevated()?;
|
||||
}
|
||||
Daemon::from_environment()?.run(command).await
|
||||
}
|
||||
|
||||
pub async fn bootstrap(options: BootstrapOptions) -> Result<BootstrapOutput> {
|
||||
ensure_supported_platform()?;
|
||||
#[cfg(windows)]
|
||||
backend::windows::ensure_not_elevated()?;
|
||||
Daemon::from_environment()?.bootstrap(options).await
|
||||
}
|
||||
|
||||
pub async fn ensure_remote_control_ready() -> Result<RemoteControlReadyOutput> {
|
||||
ensure_supported_platform()?;
|
||||
#[cfg(windows)]
|
||||
backend::windows::ensure_not_elevated()?;
|
||||
Daemon::from_environment()?
|
||||
.ensure_remote_control_ready()
|
||||
.await
|
||||
@@ -228,6 +240,8 @@ pub async fn start_remote_control_pairing() -> Result<RemoteControlPairingStartR
|
||||
|
||||
pub async fn set_remote_control(mode: RemoteControlMode) -> Result<RemoteControlOutput> {
|
||||
ensure_supported_platform()?;
|
||||
#[cfg(windows)]
|
||||
backend::windows::ensure_not_elevated()?;
|
||||
Daemon::from_environment()?.set_remote_control(mode).await
|
||||
}
|
||||
|
||||
@@ -235,18 +249,20 @@ pub async fn run_pid_update_loop(
|
||||
http_client_factory: codex_http_client::HttpClientFactory,
|
||||
) -> Result<()> {
|
||||
ensure_supported_platform()?;
|
||||
#[cfg(windows)]
|
||||
backend::windows::ensure_not_elevated()?;
|
||||
update_loop::run(http_client_factory).await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn ensure_supported_platform() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn ensure_supported_platform() -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"codex app-server daemon lifecycle is only supported on Unix platforms"
|
||||
"codex app-server daemon lifecycle is only supported on Unix and Windows platforms"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -359,7 +375,7 @@ impl Daemon {
|
||||
.await)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) async fn try_restart_if_running(
|
||||
&self,
|
||||
mode: RestartMode,
|
||||
@@ -382,6 +398,8 @@ impl Daemon {
|
||||
RestartDecision::NotReady => return Ok(RestartIfRunningOutcome::NotReady),
|
||||
RestartDecision::AlreadyCurrent => RestartIfRunningOutcome::AlreadyCurrent,
|
||||
RestartDecision::Restart => {
|
||||
#[cfg(windows)]
|
||||
backend::windows::ensure_detached_launch(managed_codex_bin)?;
|
||||
backend.stop().await?;
|
||||
let _ = self
|
||||
.start_managed_backend_with_bin(&settings, managed_codex_bin)
|
||||
@@ -398,9 +416,18 @@ impl Daemon {
|
||||
RestartIfRunningOutcome::NotRunning
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
if should_reexec_updater(updater_refresh_mode, outcome) {
|
||||
crate::update_loop::reexec_managed_updater(managed_codex_bin)?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if should_reexec_updater(updater_refresh_mode, outcome) {
|
||||
backend::pid_update_loop_backend(
|
||||
self.backend_paths_with_bin(&settings, managed_codex_bin),
|
||||
)
|
||||
.replace_current_updater()
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
@@ -564,11 +591,13 @@ impl Daemon {
|
||||
));
|
||||
}
|
||||
|
||||
if backend.is_some() {
|
||||
self.ensure_managed_codex_bin()?;
|
||||
}
|
||||
settings.remote_control_enabled = remote_control_enabled;
|
||||
settings.save(&self.settings_file).await?;
|
||||
|
||||
let app_server_version = if let Some(backend) = backend {
|
||||
self.ensure_managed_codex_bin()?;
|
||||
backend.stop().await?;
|
||||
let _ = self.start_managed_backend(&settings).await?;
|
||||
Some(self.wait_until_ready().await?.app_server_version)
|
||||
@@ -666,25 +695,32 @@ impl Daemon {
|
||||
|
||||
fn ensure_managed_codex_bin(&self) -> Result<()> {
|
||||
if self.managed_codex_bin.is_file() {
|
||||
#[cfg(windows)]
|
||||
backend::windows::ensure_detached_launch(&self.managed_codex_bin)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let managed_codex_path = self.managed_codex_bin.display();
|
||||
let install_command = if cfg!(windows) {
|
||||
"irm https://chatgpt.com/codex/install.ps1 | iex"
|
||||
} else {
|
||||
"curl -fsSL https://chatgpt.com/codex/install.sh | sh"
|
||||
};
|
||||
Err(anyhow!(
|
||||
"managed standalone Codex install not found at {managed_codex_path}\n\n\
|
||||
This command requires the standalone install managed by the Codex installer, because \
|
||||
the daemon starts and updates app-server from that fixed path.\n\n\
|
||||
Install it with:\n curl -fsSL https://chatgpt.com/codex/install.sh | sh\n\n\
|
||||
Install it with:\n {install_command}\n\n\
|
||||
Then rerun the command you just tried."
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
async fn managed_codex_version_best_effort(&self) -> Option<String> {
|
||||
managed_codex_version(&self.managed_codex_bin).await.ok()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
async fn managed_codex_version_best_effort(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
@@ -727,12 +763,18 @@ impl Daemon {
|
||||
|
||||
async fn open_operation_lock_file(&self) -> Result<tokio::fs::File> {
|
||||
if let Some(parent) = self.operation_lock_file.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!(
|
||||
"failed to create daemon state directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
if let Some(home) = parent.parent() {
|
||||
tokio::fs::create_dir_all(home).await?;
|
||||
}
|
||||
codex_uds::prepare_private_socket_directory(parent)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create daemon state directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
@@ -800,7 +842,7 @@ fn already_remote_control_status(mode: RemoteControlMode) -> RemoteControlStatus
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn restart_decision(
|
||||
mode: RestartMode,
|
||||
info: Option<&client::ProbeInfo>,
|
||||
@@ -817,7 +859,7 @@ fn restart_decision(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn should_reexec_updater(
|
||||
updater_refresh_mode: UpdaterRefreshMode,
|
||||
outcome: RestartIfRunningOutcome,
|
||||
@@ -842,12 +884,12 @@ fn try_lock_file(file: &tokio::fs::File) -> Result<bool> {
|
||||
Err(err).context("failed to lock daemon operation")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn try_lock_file(_file: &tokio::fs::File) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(all(test, any(unix, windows)))]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
@@ -1006,6 +1048,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_creates_missing_home_parent() {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let state = temp.path().join("missing-home").join("daemon-state");
|
||||
let daemon = Daemon {
|
||||
socket_path: state.join("server.sock"),
|
||||
pid_file: state.join("server.pid"),
|
||||
update_pid_file: state.join("updater.pid"),
|
||||
operation_lock_file: state.join("daemon.lock"),
|
||||
settings_file: state.join("settings.json"),
|
||||
managed_codex_bin: state.join("missing-codex"),
|
||||
};
|
||||
assert_eq!(
|
||||
daemon
|
||||
.run(super::LifecycleCommand::Stop)
|
||||
.await
|
||||
.expect("stop on fresh home")
|
||||
.status,
|
||||
LifecycleStatus::NotRunning,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_ready_context_reports_daemon_app_server_before_stderr() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
|
||||
+30
-27
@@ -1,30 +1,35 @@
|
||||
//! Resolves both package and legacy standalone layouts and compares installed executables.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context;
|
||||
#[cfg(unix)]
|
||||
use anyhow::Result;
|
||||
#[cfg(unix)]
|
||||
use anyhow::anyhow;
|
||||
#[cfg(unix)]
|
||||
use sha2::Digest;
|
||||
#[cfg(unix)]
|
||||
use sha2::Sha256;
|
||||
#[cfg(unix)]
|
||||
use tokio::fs;
|
||||
#[cfg(unix)]
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Returns the packaged executable when present, otherwise an existing legacy executable.
|
||||
/// If neither exists, returns the expected packaged path on Windows and preserves the
|
||||
/// historical legacy fallback on Unix. This is path selection, not existence validation:
|
||||
/// launch operations reject a missing executable separately, while commands such as
|
||||
/// stop can still run after the managed install has been removed.
|
||||
pub(crate) fn managed_codex_bin(codex_home: &Path) -> PathBuf {
|
||||
codex_home
|
||||
let current = codex_home
|
||||
.join("packages")
|
||||
.join("standalone")
|
||||
.join("current")
|
||||
.join(managed_codex_file_name())
|
||||
.join("current");
|
||||
let packaged = current.join("bin").join(managed_codex_file_name());
|
||||
let legacy = current.join(managed_codex_file_name());
|
||||
if packaged.is_file() || (cfg!(windows) && !legacy.is_file()) {
|
||||
packaged
|
||||
} else {
|
||||
legacy
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn resolved_managed_codex_bin(codex_bin: &Path) -> Result<PathBuf> {
|
||||
fs::canonicalize(codex_bin).await.with_context(|| {
|
||||
format!(
|
||||
@@ -34,18 +39,16 @@ pub(crate) async fn resolved_managed_codex_bin(codex_bin: &Path) -> Result<PathB
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn managed_codex_version(codex_bin: &Path) -> Result<String> {
|
||||
let output = Command::new(codex_bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to invoke managed Codex binary {}",
|
||||
codex_bin.display()
|
||||
)
|
||||
})?;
|
||||
let mut command = Command::new(codex_bin);
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
|
||||
let output = command.arg("--version").output().await.with_context(|| {
|
||||
format!(
|
||||
"failed to invoke managed Codex binary {}",
|
||||
codex_bin.display()
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"managed Codex binary {} exited with status {}",
|
||||
@@ -63,13 +66,11 @@ pub(crate) async fn managed_codex_version(codex_bin: &Path) -> Result<String> {
|
||||
parse_codex_version(&stdout)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ExecutableIdentity {
|
||||
digest: [u8; 32],
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn executable_identity(executable: &Path) -> Result<ExecutableIdentity> {
|
||||
let bytes = fs::read(executable)
|
||||
.await
|
||||
@@ -77,7 +78,6 @@ pub(crate) async fn executable_identity(executable: &Path) -> Result<ExecutableI
|
||||
Ok(executable_identity_from_bytes(&bytes))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn executable_identity_from_bytes(bytes: &[u8]) -> ExecutableIdentity {
|
||||
ExecutableIdentity {
|
||||
digest: Sha256::digest(bytes).into(),
|
||||
@@ -88,7 +88,6 @@ fn managed_codex_file_name() -> &'static str {
|
||||
if cfg!(windows) { "codex.exe" } else { "codex" }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn parse_codex_version(output: &str) -> Result<String> {
|
||||
let version = output
|
||||
.split_whitespace()
|
||||
@@ -98,6 +97,10 @@ fn parse_codex_version(output: &str) -> Result<String> {
|
||||
Ok(version.to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
#[path = "managed_install_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "managed_install_path_tests.rs"]
|
||||
mod path_tests;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn discovers_package_and_legacy_installs() {
|
||||
let home = tempfile::TempDir::new().expect("home");
|
||||
let current = home.path().join("packages/standalone/current");
|
||||
let legacy = current.join(super::managed_codex_file_name());
|
||||
let expected = if cfg!(windows) {
|
||||
current.join("bin").join("codex.exe")
|
||||
} else {
|
||||
legacy.clone()
|
||||
};
|
||||
assert_eq!(super::managed_codex_bin(home.path()), expected);
|
||||
std::fs::create_dir_all(¤t).expect("current directory");
|
||||
std::fs::write(&legacy, b"legacy").expect("legacy executable");
|
||||
assert_eq!(super::managed_codex_bin(home.path()), legacy);
|
||||
let packaged = current.join("bin").join(super::managed_codex_file_name());
|
||||
std::fs::create_dir(current.join("bin")).expect("bin directory");
|
||||
std::fs::write(&packaged, b"packaged").expect("packaged executable");
|
||||
assert_eq!(super::managed_codex_bin(home.path()), packaged);
|
||||
}
|
||||
+73
-45
@@ -1,27 +1,19 @@
|
||||
#[cfg(unix)]
|
||||
use std::process::Command as StdCommand;
|
||||
#[cfg(unix)]
|
||||
use std::process::Stdio;
|
||||
#[cfg(unix)]
|
||||
use std::time::Duration;
|
||||
//! Installs updates, validates the server restart, then transfers updater ownership.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::process::Command as StdCommand;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
#[cfg(not(unix))]
|
||||
use anyhow::bail;
|
||||
#[cfg(unix)]
|
||||
use codex_http_client::ClientRouteClass;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
#[cfg(unix)]
|
||||
use codex_http_client::RouteAwareClientPool;
|
||||
#[cfg(unix)]
|
||||
use futures::FutureExt;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
#[cfg(unix)]
|
||||
use tokio::io::AsyncWriteExt;
|
||||
#[cfg(unix)]
|
||||
use tokio::process::Command;
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::Signal;
|
||||
@@ -29,38 +21,44 @@ use tokio::signal::unix::Signal;
|
||||
use tokio::signal::unix::SignalKind;
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::signal;
|
||||
#[cfg(unix)]
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::Daemon;
|
||||
#[cfg(unix)]
|
||||
use crate::RestartIfRunningOutcome;
|
||||
#[cfg(unix)]
|
||||
use crate::RestartMode;
|
||||
#[cfg(unix)]
|
||||
use crate::UpdaterRefreshMode;
|
||||
#[cfg(unix)]
|
||||
use crate::managed_install::ExecutableIdentity;
|
||||
#[cfg(unix)]
|
||||
use crate::managed_install::executable_identity;
|
||||
#[cfg(unix)]
|
||||
use crate::managed_install::resolved_managed_codex_bin;
|
||||
|
||||
#[cfg(unix)]
|
||||
const INITIAL_UPDATE_DELAY: Duration = Duration::from_secs(5 * 60);
|
||||
#[cfg(unix)]
|
||||
const RESTART_RETRY_INTERVAL: Duration = Duration::from_millis(50);
|
||||
#[cfg(unix)]
|
||||
const UPDATE_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||
#[cfg(unix)]
|
||||
const INSTALL_URL: &str = "https://chatgpt.com/codex/install.sh";
|
||||
#[cfg(windows)]
|
||||
const INSTALL_URL: &str = "https://chatgpt.com/codex/install.ps1";
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn run(http_client_factory: HttpClientFactory) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
let mut terminate =
|
||||
signal(SignalKind::terminate()).context("failed to install updater shutdown handler")?;
|
||||
#[cfg(windows)]
|
||||
let updater = {
|
||||
let daemon = Daemon::from_environment()?;
|
||||
crate::backend::pid_update_loop_backend(
|
||||
daemon.backend_paths(&daemon.load_settings().await?),
|
||||
)
|
||||
};
|
||||
#[cfg(windows)]
|
||||
updater.wait_for_ownership().await?;
|
||||
#[cfg(windows)]
|
||||
let mut terminate = Signal;
|
||||
#[cfg(windows)]
|
||||
let _installer_job = crate::backend::windows::updater_job()?;
|
||||
let running_updater_identity = current_updater_identity().await?;
|
||||
#[cfg(windows)]
|
||||
updater.mark_ready().await?;
|
||||
let http = RouteAwareClientPool::new_without_request_logging(
|
||||
http_client_factory,
|
||||
ClientRouteClass::Other,
|
||||
@@ -69,6 +67,10 @@ pub(crate) async fn run(http_client_factory: HttpClientFactory) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
loop {
|
||||
// Failed successor cleanup leaves its PID published. The predecessor
|
||||
// must stop instead of installing again without ownership.
|
||||
#[cfg(windows)]
|
||||
updater.wait_for_ownership().await?;
|
||||
match update_once(&http, &running_updater_identity, &mut terminate).await {
|
||||
Ok(UpdateLoopControl::Continue) | Err(_) => {}
|
||||
Ok(UpdateLoopControl::Stop) => return Ok(()),
|
||||
@@ -79,12 +81,6 @@ pub(crate) async fn run(http_client_factory: HttpClientFactory) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) async fn run(_http_client_factory: HttpClientFactory) -> Result<()> {
|
||||
bail!("pid-managed updater loop is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn sleep_or_terminate(duration: Duration, terminate: &mut Signal) -> bool {
|
||||
tokio::select! {
|
||||
_ = sleep(duration) => false,
|
||||
@@ -92,19 +88,23 @@ async fn sleep_or_terminate(duration: Duration, terminate: &mut Signal) -> bool
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
enum UpdateLoopControl {
|
||||
Continue,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn update_once(
|
||||
http: &RouteAwareClientPool,
|
||||
running_updater_identity: &ExecutableIdentity,
|
||||
terminate: &mut Signal,
|
||||
) -> Result<UpdateLoopControl> {
|
||||
#[cfg(unix)]
|
||||
install_latest_standalone(http).await?;
|
||||
#[cfg(windows)]
|
||||
tokio::select! {
|
||||
result = install_latest_standalone(http) => result?,
|
||||
_ = terminate.recv() => return Ok(UpdateLoopControl::Stop),
|
||||
}
|
||||
|
||||
let daemon = Daemon::from_environment()?;
|
||||
let managed_codex_bin = resolved_managed_codex_bin(&daemon.managed_codex_bin).await?;
|
||||
@@ -125,19 +125,26 @@ async fn update_once(
|
||||
return Ok(UpdateLoopControl::Stop);
|
||||
}
|
||||
}
|
||||
_ => return Ok(UpdateLoopControl::Continue),
|
||||
RestartIfRunningOutcome::Restarted => {
|
||||
#[cfg(windows)]
|
||||
if updater_refresh_mode == UpdaterRefreshMode::ReexecIfManagedBinaryChanged {
|
||||
return Ok(UpdateLoopControl::Stop);
|
||||
}
|
||||
return Ok(UpdateLoopControl::Continue);
|
||||
}
|
||||
RestartIfRunningOutcome::NotRunning
|
||||
| RestartIfRunningOutcome::NotReady
|
||||
| RestartIfRunningOutcome::AlreadyCurrent => return Ok(UpdateLoopControl::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn current_updater_identity() -> Result<ExecutableIdentity> {
|
||||
let current_exe =
|
||||
std::env::current_exe().context("failed to resolve current updater executable")?;
|
||||
executable_identity(¤t_exe).await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn update_modes_for_identities(
|
||||
running_updater_identity: &ExecutableIdentity,
|
||||
managed_identity: &ExecutableIdentity,
|
||||
@@ -165,12 +172,25 @@ pub(crate) fn reexec_managed_updater(managed_codex_bin: &std::path::Path) -> Res
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn install_latest_standalone(http: &RouteAwareClientPool) -> Result<()> {
|
||||
async fn install_latest_standalone(http: &impl InstallerHttp) -> Result<()> {
|
||||
let script = fetch_installer_script(http).await?;
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.arg("-s")
|
||||
#[cfg(unix)]
|
||||
let mut command = {
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.arg("-s");
|
||||
command
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut command = {
|
||||
let mut command = Command::new("powershell.exe");
|
||||
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
|
||||
command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "try { Invoke-Expression ([Console]::In.ReadToEnd()) } catch { Write-Error $_; exit 1 }"])
|
||||
.env("CODEX_NON_INTERACTIVE", "1")
|
||||
.kill_on_drop(true);
|
||||
command
|
||||
};
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
@@ -197,7 +217,6 @@ async fn install_latest_standalone(http: &RouteAwareClientPool) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn fetch_installer_script(http: &impl InstallerHttp) -> Result<Vec<u8>> {
|
||||
match http.get(INSTALL_URL).await? {
|
||||
InstallerResponse::Success(body) => Ok(body),
|
||||
@@ -207,14 +226,12 @@ async fn fetch_installer_script(http: &impl InstallerHttp) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum InstallerResponse {
|
||||
Success(Vec<u8>),
|
||||
Unsuccessful { status: u16 },
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// HTTP boundary used to download the standalone installer.
|
||||
///
|
||||
/// Implementations must issue a GET for the supplied URL, return exact response bytes for a
|
||||
@@ -226,7 +243,6 @@ trait InstallerHttp: Send + Sync {
|
||||
) -> impl std::future::Future<Output = Result<InstallerResponse>> + Send + 'a;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl InstallerHttp for RouteAwareClientPool {
|
||||
async fn get(&self, url: &str) -> Result<InstallerResponse> {
|
||||
let response = RouteAwareClientPool::get(self, url)
|
||||
@@ -247,6 +263,18 @@ impl InstallerHttp for RouteAwareClientPool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
#[path = "update_loop_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(windows)]
|
||||
struct Signal;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Signal {
|
||||
async fn recv(&mut self) -> Option<()> {
|
||||
// An unreadable control path must stop the updater rather than disable shutdown.
|
||||
let _ = codex_app_server_transport::daemon_shutdown_signal().await;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,3 +92,24 @@ impl InstallerHttp for FakeInstallerHttp {
|
||||
Ok(self.response.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn powershell_installer_is_noninteractive_and_reports_script_failure() {
|
||||
let valid = FakeInstallerHttp::new(InstallerResponse::Success(
|
||||
br#"
|
||||
function Test-Installer {
|
||||
if ($env:CODEX_NON_INTERACTIVE -ne '1') { throw 'interactive installer' }
|
||||
}
|
||||
Test-Installer
|
||||
"#
|
||||
.to_vec(),
|
||||
));
|
||||
super::install_latest_standalone(&valid)
|
||||
.await
|
||||
.expect("installer succeeds");
|
||||
let failing = FakeInstallerHttp::new(InstallerResponse::Success(
|
||||
b"throw 'installer failed'".to_vec(),
|
||||
));
|
||||
assert!(super::install_latest_standalone(&failing).await.is_err());
|
||||
}
|
||||
|
||||
@@ -9,3 +9,11 @@ codex_rust_crate(
|
||||
allow_empty = True,
|
||||
),
|
||||
)
|
||||
|
||||
alias(
|
||||
name = "schema-generator",
|
||||
testonly = True,
|
||||
actual = ":app-server-protocol-unit-tests-bin",
|
||||
tags = ["manual"],
|
||||
visibility = ["//bazel/schema:__pkg__"],
|
||||
)
|
||||
|
||||
+3
@@ -17,11 +17,14 @@ anyhow = { workspace = true }
|
||||
codex-experimental-api-macros = { workspace = true }
|
||||
codex-app-server-protocol-noop-macros = { workspace = true }
|
||||
codex-extension-items = { workspace = true }
|
||||
codex-history = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-rollout = { workspace = true }
|
||||
codex-secrets = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-path-uri = { workspace = true }
|
||||
codex-utils-redacted-string = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_with = { workspace = true }
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "User has approved this MCP tool call and wants to amend its policy so matching future calls are automatically approved across sessions.",
|
||||
"enum": [
|
||||
"approved_mcp_policy_amendment"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.",
|
||||
|
||||
+552
-6
@@ -150,6 +150,13 @@
|
||||
"includeTools": {
|
||||
"description": "When true, include display-only public tool summaries in the returned metadata.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"threadId": {
|
||||
"description": "Optional loaded thread id used to evaluate effective app configuration.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -599,6 +606,18 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ConfigurationReasoning": {
|
||||
"description": "Reasoning settings interpreted by the backend for the routed model.",
|
||||
"properties": {
|
||||
"effort": {
|
||||
"$ref": "#/definitions/ReasoningEffort"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"effort"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditParams": {
|
||||
"properties": {
|
||||
"creditId": {
|
||||
@@ -720,6 +739,15 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CyberAccessProgram": {
|
||||
"description": "Requested cyber treatment for a ChatGPT-authenticated Codex turn. Authorization and model-tier restrictions remain server-owned.",
|
||||
"enum": [
|
||||
"standard",
|
||||
"daybreakBlue",
|
||||
"daybreakRed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"DynamicToolNamespaceTool": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -1496,6 +1524,32 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GetAccountRateLimitsParams": {
|
||||
"description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.",
|
||||
"properties": {
|
||||
"excludeResetCreditDetails": {
|
||||
"description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsLunaReserve": {
|
||||
"description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GetAccountTokenUsageParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HookMigration": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -1833,6 +1887,41 @@
|
||||
],
|
||||
"title": "AmazonBedrockLoginAccountParams",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "[UNSTABLE] Managed Amazon Bedrock AWS access key login is experimental.",
|
||||
"properties": {
|
||||
"accessKeyId": {
|
||||
"type": "string"
|
||||
},
|
||||
"region": {
|
||||
"type": "string"
|
||||
},
|
||||
"secretAccessKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessionToken": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"amazonBedrockAccessKeys"
|
||||
],
|
||||
"title": "AmazonBedrockAccessKeysLoginAccountParamsType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessKeyId",
|
||||
"region",
|
||||
"secretAccessKey",
|
||||
"type"
|
||||
],
|
||||
"title": "AmazonBedrockAccessKeysLoginAccountParams",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1893,6 +1982,19 @@
|
||||
},
|
||||
"McpResourceReadParams": {
|
||||
"properties": {
|
||||
"connectorId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"originCallId": {
|
||||
"description": "Originating MCP tool call used to select the resource's app.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1923,8 +2025,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"McpServerOauthClientRegistration": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"cimd",
|
||||
"dcr"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpServerOauthLoginParams": {
|
||||
"properties": {
|
||||
"clientRegistration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/McpServerOauthClientRegistration"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Registration strategy for this login only; omission selects automatic discovery."
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2179,6 +2300,13 @@
|
||||
},
|
||||
"PluginInstallParams": {
|
||||
"properties": {
|
||||
"installAttemptId": {
|
||||
"description": "Client-generated identifier used to correlate one installation attempt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplacePath": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2295,6 +2423,18 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginReconcileParams": {
|
||||
"properties": {
|
||||
"reason": {
|
||||
"description": "Optional client-provided reason recorded with the reconciliation attempt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSearchScope": {
|
||||
"enum": [
|
||||
"global",
|
||||
@@ -2506,6 +2646,24 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProjectRoot": {
|
||||
"properties": {
|
||||
"path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProjectSortKey": {
|
||||
"enum": [
|
||||
"position",
|
||||
"recencyAt"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RealtimeConversationVersion": {
|
||||
"enum": [
|
||||
"v1",
|
||||
@@ -2982,7 +3140,10 @@
|
||||
{
|
||||
"properties": {
|
||||
"call_id": {
|
||||
"type": "string"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": [
|
||||
@@ -3000,6 +3161,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/definitions/FunctionCallOutputBody"
|
||||
},
|
||||
@@ -3012,7 +3185,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"call_id",
|
||||
"output",
|
||||
"type"
|
||||
],
|
||||
@@ -3302,6 +3474,27 @@
|
||||
"title": "CompactionResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "A durable input control interpreted by the backend at its position in history.",
|
||||
"properties": {
|
||||
"reasoning": {
|
||||
"$ref": "#/definitions/ConfigurationReasoning"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"configuration_update"
|
||||
],
|
||||
"title": "ConfigurationUpdateResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reasoning",
|
||||
"type"
|
||||
],
|
||||
"title": "ConfigurationUpdateResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"type": {
|
||||
@@ -3494,7 +3687,7 @@
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`)."
|
||||
"description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`). Detached delivery is deprecated and emits `deprecationNotice`. Use `thread/start` followed by an inline review for a separate review thread."
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/definitions/ReviewTarget"
|
||||
@@ -4000,6 +4193,10 @@
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"excludeTurns": {
|
||||
"description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking. Full-history hydration is deprecated for paginated threads; use this with `thread/turns/list` and `thread/items/list` instead.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastTurnId": {
|
||||
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
|
||||
"type": [
|
||||
@@ -4147,6 +4344,51 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadItemsListParams": {
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last item.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional item page size.",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional item pagination direction; defaults to ascending."
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"description": "Optional turn id to filter by. When omitted, returns items across the thread.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadListCwdFilter": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4206,6 +4448,16 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"originators": {
|
||||
"description": "Optional originator allowlist, matching any supplied value exactly. Supported by hosted backends only; the local app-server rejects a nonempty list. Omitted or empty lists leave originators unrestricted.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"searchTerm": {
|
||||
"description": "Optional substring filter for the extracted thread title.",
|
||||
"type": [
|
||||
@@ -4338,7 +4590,7 @@
|
||||
"ThreadReadParams": {
|
||||
"properties": {
|
||||
"includeTurns": {
|
||||
"description": "When true, include turns and their items from rollout history.",
|
||||
"description": "When true, include turns and their items from rollout history. Full-history hydration is deprecated for paginated threads; prefer a metadata-only read and page with `thread/turns/list` and `thread/items/list`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"threadId": {
|
||||
@@ -4443,6 +4695,27 @@
|
||||
],
|
||||
"title": "WebrtcThreadRealtimeStartTransport",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"callId": {
|
||||
"description": "Identifier of a realtime call already created and negotiated by the client.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"existingCall"
|
||||
],
|
||||
"title": "ExistingCallThreadRealtimeStartTransportType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"callId",
|
||||
"type"
|
||||
],
|
||||
"title": "ExistingCallThreadRealtimeStartTransport",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4531,6 +4804,10 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"excludeTurns": {
|
||||
"description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming. Full-history hydration is deprecated for paginated threads; use this with `thread/turns/list` and `thread/items/list` instead.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"model": {
|
||||
"description": "Configuration overrides for the resumed thread, if any.",
|
||||
"type": [
|
||||
@@ -4579,6 +4856,23 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadRevertParams": {
|
||||
"description": "Replace a paginated thread's durable history with the prefix before one turn.\n\nThis only changes persisted conversation history. It does not revert local file changes.",
|
||||
"properties": {
|
||||
"beforeTurnId": {
|
||||
"description": "Turn excluded from the replacement history, together with every later turn.",
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"beforeTurnId",
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadRollbackParams": {
|
||||
"description": "DEPRECATED: `thread/rollback` will be removed soon.",
|
||||
"properties": {
|
||||
@@ -4606,9 +4900,38 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSectionAppearance": {
|
||||
"description": "Extensible visual presentation for a custom thread section.",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadSectionCreateParams": {
|
||||
"description": "Parameters for creating an independently persisted thread section.",
|
||||
"properties": {
|
||||
"appearance": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ThreadSectionAppearance"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"name": {
|
||||
"description": "The user-visible name of the section.",
|
||||
"type": "string"
|
||||
@@ -4685,6 +5008,17 @@
|
||||
"ThreadSectionUpdateParams": {
|
||||
"description": "Parameters for updating an independently persisted thread section.",
|
||||
"properties": {
|
||||
"appearance": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ThreadSectionAppearance"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Omit to preserve appearance, use `null` to clear it, or provide a replacement."
|
||||
},
|
||||
"name": {
|
||||
"description": "The updated user-visible name of the section.",
|
||||
"type": "string"
|
||||
@@ -4723,6 +5057,14 @@
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeoutMs": {
|
||||
"description": "Maximum execution time in milliseconds. Defaults to one hour when omitted or null. Must be non-negative; zero requests an immediate timeout, not unlimited execution. Does not affect the immediate RPC acknowledgement.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4887,6 +5229,55 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadTurnsListParams": {
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"itemsView": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TurnItemsView"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "How much item detail to include for each returned turn; defaults to summary."
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional turn page size.",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional turn pagination direction; defaults to descending."
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadUnarchiveParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
@@ -5067,6 +5458,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"serviceTierForTurn": {
|
||||
"description": "Override the service tier only when this request starts a new turn. Use \"default\" for standard speed. Omitted or null inherits the thread's tier. Does not change the thread's tier or a turn being steered.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -5080,6 +5478,23 @@
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"toolOutput": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TurnToolOutput"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"turnTrigger": {
|
||||
"description": "Optional source classification for the caller that starts this turn. Ignored when this request steers an already-active turn.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -5117,6 +5532,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TurnToolOutput": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/definitions/FunctionCallOutputBody"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"output"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserInput": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5763,6 +6199,30 @@
|
||||
"title": "Thread/rollbackRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/revert"
|
||||
],
|
||||
"title": "Thread/revertRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadRevertParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/revertRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -5931,6 +6391,54 @@
|
||||
"title": "Thread/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/turns/list"
|
||||
],
|
||||
"title": "Thread/turns/listRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadTurnsListParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/turns/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/items/list"
|
||||
],
|
||||
"title": "Thread/items/listRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadItemsListParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/items/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Append raw Responses API items to the thread history without starting a user turn.",
|
||||
"properties": {
|
||||
@@ -6148,6 +6656,30 @@
|
||||
"title": "Plugin/installedRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/reconcile"
|
||||
],
|
||||
"title": "Plugin/reconcileRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginReconcileParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/reconcileRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -7142,7 +7674,14 @@
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GetAccountRateLimitsParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7189,7 +7728,14 @@
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GetAccountTokenUsageParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+18
-1
@@ -269,6 +269,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionApprovalKind": {
|
||||
"description": "Distinguishes a command approval from input sent to an existing terminal.",
|
||||
"enum": [
|
||||
"command",
|
||||
"writeStdin"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"FileSystemAccessMode": {
|
||||
"enum": [
|
||||
"read",
|
||||
@@ -525,7 +533,7 @@
|
||||
},
|
||||
"properties": {
|
||||
"approvalId": {
|
||||
"description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.",
|
||||
"description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing. Stdin approvals also use a distinct callback id; inspect `kind` to distinguish them.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -570,6 +578,15 @@
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommandExecutionApprovalKind"
|
||||
}
|
||||
],
|
||||
"default": "command",
|
||||
"description": "Kind of action under review. Defaults to `command` for older servers."
|
||||
},
|
||||
"networkApprovalContext": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "User has approved this MCP tool call and wants to amend its policy so matching future calls are automatically approved across sessions.",
|
||||
"enum": [
|
||||
"approved_mcp_policy_amendment"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.",
|
||||
|
||||
+21
@@ -578,6 +578,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"openaiForm"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
|
||||
+1
-5
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AdditionalFileSystemPermissions": {
|
||||
"properties": {
|
||||
"entries": {
|
||||
@@ -294,7 +290,7 @@
|
||||
},
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"$ref": "#/definitions/LegacyAppPathString"
|
||||
},
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AdditionalFileSystemPermissions": {
|
||||
"properties": {
|
||||
"entries": {
|
||||
@@ -342,10 +338,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionApprovalKind": {
|
||||
"description": "Distinguishes a command approval from input sent to an existing terminal.",
|
||||
"enum": [
|
||||
"command",
|
||||
"writeStdin"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CommandExecutionRequestApprovalParams": {
|
||||
"properties": {
|
||||
"approvalId": {
|
||||
"description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.",
|
||||
"description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing. Stdin approvals also use a distinct callback id; inspect `kind` to distinguish them.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -390,6 +394,15 @@
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommandExecutionApprovalKind"
|
||||
}
|
||||
],
|
||||
"default": "command",
|
||||
"description": "Kind of action under review. Defaults to `command` for older servers."
|
||||
},
|
||||
"networkApprovalContext": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1419,6 +1432,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"openaiForm"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"_meta": true,
|
||||
@@ -1628,7 +1662,7 @@
|
||||
"PermissionsRequestApprovalParams": {
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"$ref": "#/definitions/LegacyAppPathString"
|
||||
},
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
|
||||
+2852
-27
File diff suppressed because it is too large
Load Diff
+2805
-25
File diff suppressed because it is too large
Load Diff
+9
@@ -38,6 +38,8 @@
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"edu_plus",
|
||||
"edu_pro",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -86,6 +88,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"normalModelSlug": {
|
||||
"description": "Normal model whose display name and reasoning options describe this quota alias.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+9
@@ -52,6 +52,13 @@
|
||||
"bedrockApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Amazon Bedrock AWS access keys managed by Codex.",
|
||||
"enum": [
|
||||
"bedrockAccessKeys"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -71,6 +78,8 @@
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"edu_plus",
|
||||
"edu_pro",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
"includeTools": {
|
||||
"description": "When true, include display-only public tool summaries in the returned metadata.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"threadId": {
|
||||
"description": "Optional loaded thread id used to evaluate effective app configuration.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"provider",
|
||||
"threadId",
|
||||
"turnId"
|
||||
],
|
||||
"title": "AuthRecoveryNotification",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -5,6 +5,13 @@
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AllowDenyRequirement": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AnalyticsConfig": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
@@ -55,6 +62,17 @@
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"links": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AppLinksConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Per-account approval settings keyed by link ID."
|
||||
},
|
||||
"open_world_enabled": {
|
||||
"type": [
|
||||
"boolean",
|
||||
@@ -74,6 +92,36 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"AppLinkConfig": {
|
||||
"description": "Approval settings for a connected account within an app.",
|
||||
"properties": {
|
||||
"approvals_reviewer": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ApprovalsReviewer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_tools_approval_mode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AppToolApproval"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"AppLinksConfig": {
|
||||
"description": "Account settings for a single app.",
|
||||
"type": "object"
|
||||
},
|
||||
"AppToolApproval": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -237,6 +285,178 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"BrowserUseConfig": {
|
||||
"properties": {
|
||||
"allow_history_access": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"default_origin_policy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/BrowserUseOriginPolicyConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"origins": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/BrowserUseOriginPolicyConfig"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"BrowserUseOriginPolicyConfig": {
|
||||
"properties": {
|
||||
"access": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"downloads": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"full_cdp_access": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"uploads": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ComputerUseConfig": {
|
||||
"properties": {
|
||||
"default_app_access": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"macos": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ComputerUseMacosConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"windows": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ComputerUseWindowsConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ComputerUseMacosConfig": {
|
||||
"properties": {
|
||||
"bundle_ids": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ComputerUseWindowsConfig": {
|
||||
"properties": {
|
||||
"aumids": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"exes": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ComputerUseWindowsExeConfig"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ComputerUseWindowsExeConfig": {
|
||||
"properties": {
|
||||
"access": {
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
"binary_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"product_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"publisher_name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"access",
|
||||
"product_name",
|
||||
"publisher_name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Config": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
@@ -271,12 +491,32 @@
|
||||
],
|
||||
"description": "[UNSTABLE] Optional default for where approval requests are routed for review."
|
||||
},
|
||||
"browser_use": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/BrowserUseConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"compact_prompt": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"computer_use": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ComputerUseConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"desktop": {
|
||||
"additionalProperties": true,
|
||||
"type": [
|
||||
@@ -477,6 +717,32 @@
|
||||
},
|
||||
"ConfigLayerSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Default configuration supplied with the installed Codex package.",
|
||||
"properties": {
|
||||
"file": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Path to the packaged default configuration file."
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"packagedDefaults"
|
||||
],
|
||||
"title": "PackagedDefaultsConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"type"
|
||||
],
|
||||
"title": "PackagedDefaultsConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Managed preferences layer delivered by MDM (macOS only).",
|
||||
"properties": {
|
||||
|
||||
+352
@@ -1,6 +1,47 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AllowDenyRequirement": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ApplicationNetworkRequirements": {
|
||||
"properties": {
|
||||
"domains": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/NetworkDomainPermission"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "When enabled, only explicitly allowed exact domains may be contacted.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"domains",
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ApplicationRequirements": {
|
||||
"properties": {
|
||||
"network": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ApplicationNetworkRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ApprovalsReviewer": {
|
||||
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
|
||||
"enum": [
|
||||
@@ -59,13 +100,174 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"AutoReviewRequirements": {
|
||||
"properties": {
|
||||
"ignoreRules": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"requiredOnModels": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"BrowserUseAccessApprovalLifetime": {
|
||||
"enum": [
|
||||
"turn",
|
||||
"thread"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"BrowserUseOriginPolicy": {
|
||||
"properties": {
|
||||
"access": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"accessApprovalLifetime": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/BrowserUseAccessApprovalLifetime"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"autoReview": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"downloads": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fullCdpAccess": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"persistentApproval": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"uploads": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"BrowserUseRequirements": {
|
||||
"properties": {
|
||||
"allowGlobalPersistentApproval": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowHistoryAccess": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowWebmcp": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"defaultOriginPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/BrowserUseOriginPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"disableAutoReview": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"origins": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/BrowserUseOriginPolicy"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CliAuthCredentialsStoreMode": {
|
||||
"enum": [
|
||||
"file",
|
||||
"keyring",
|
||||
"auto",
|
||||
"ephemeral"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ComputerUseMacosRequirements": {
|
||||
"properties": {
|
||||
"bundleIds": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -77,18 +279,114 @@
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowPersistentApproval": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"defaultAppAccess": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"macos": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ComputerUseMacosRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"windows": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ComputerUseWindowsRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ComputerUseWindowsExeRequirement": {
|
||||
"properties": {
|
||||
"access": {
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
"binaryName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"productName": {
|
||||
"type": "string"
|
||||
},
|
||||
"publisherName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"access",
|
||||
"productName",
|
||||
"publisherName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ComputerUseWindowsRequirements": {
|
||||
"properties": {
|
||||
"aumids": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/AllowDenyRequirement"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"exes": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ComputerUseWindowsExeRequirement"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ConfigRequirements": {
|
||||
"properties": {
|
||||
"additionalDeveloperInstructions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowAppshots": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowBrowserAndComputerUse": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowLoginShell": {
|
||||
"type": [
|
||||
"boolean",
|
||||
@@ -152,6 +450,16 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"autoReview": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AutoReviewRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"browserUse": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -162,12 +470,28 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"chatgptBaseUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"checkForUpdateOnStartup": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"cliAuthCredentialsStore": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CliAuthCredentialsStoreMode"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"computerUse": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -213,6 +537,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"inAppBrowser": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/InAppBrowserRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"logDir": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -413,8 +747,26 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"InAppBrowserRequirements": {
|
||||
"properties": {
|
||||
"allowExternalBrowserSettingsImport": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ManagedHooksRequirements": {
|
||||
"properties": {
|
||||
"Interrupt": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PermissionRequest": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user