Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
313 lines
10 KiB
Go
313 lines
10 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CodexAppServerProbeInput is the side-effect-free subset of an app-server
|
|
// launch. It deliberately has no Session: the probe verifies only the formal
|
|
// initialize handshake and never creates a Codex thread or turn.
|
|
type CodexAppServerProbeInput struct {
|
|
Command []string
|
|
Env []string
|
|
CWD string
|
|
Host HostMetadata
|
|
ReadAccount bool
|
|
ReadRateLimits bool
|
|
StartupTimeout time.Duration
|
|
HandshakeTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
Transport ProcessTransport
|
|
}
|
|
|
|
// CodexAppServerAccountState is the account/read authentication result. An
|
|
// unknown state means the app-server could not return a structurally valid
|
|
// account response and must not be treated as authenticated.
|
|
type CodexAppServerAccountState string
|
|
|
|
const (
|
|
CodexAppServerAccountUnknown CodexAppServerAccountState = "unknown"
|
|
CodexAppServerAccountAuthenticated CodexAppServerAccountState = "authenticated"
|
|
CodexAppServerAccountRequired CodexAppServerAccountState = "required"
|
|
)
|
|
|
|
// CodexAppServerProbeResult keeps command-start and protocol-handshake facts
|
|
// separate. Callers must use ProtocolReady, not CommandStarted, as runtime
|
|
// capability evidence.
|
|
type CodexAppServerProbeResult struct {
|
|
CommandStarted bool
|
|
ProtocolReady bool
|
|
AccountRead bool
|
|
AccountState CodexAppServerAccountState
|
|
AccountLabel string
|
|
AuthMethod string
|
|
RateLimitsRead bool
|
|
RateLimits map[string]any
|
|
CommandCategory string
|
|
ProtocolCategory string
|
|
AccountCategory string
|
|
RateLimitsCategory string
|
|
// Category is the compatibility projection of the failing stage.
|
|
Category string
|
|
Message string
|
|
StderrTail string
|
|
Duration time.Duration
|
|
}
|
|
|
|
const (
|
|
CodexProbeSpawnFailed = "spawn_failed"
|
|
CodexProbeStartupTimeout = "startup_timeout"
|
|
CodexProbeCanceled = "probe_canceled"
|
|
CodexProbeHandshakeTimeout = "handshake_timeout"
|
|
CodexProbeInvalidResponse = "invalid_protocol_response"
|
|
CodexProbeStdioClosed = "stdio_closed"
|
|
CodexProbeProtocolFailure = "protocol_failure"
|
|
CodexProbeUnknownRuntimeFail = "unknown_runtime_failure"
|
|
)
|
|
|
|
// ProbeCodexAppServer reuses the production ProcessTransport, JSON-RPC client,
|
|
// typed initialize request and initialized notification. ReadAccount calls the
|
|
// same account/read method used during session startup. ReadRateLimits calls
|
|
// account/rateLimits/read, which requires a provider-accepted OAuth session.
|
|
// The connection is always closed before returning; no user-facing Codex state
|
|
// is created.
|
|
func ProbeCodexAppServer(ctx context.Context, input CodexAppServerProbeInput) (result CodexAppServerProbeResult) {
|
|
startedAt := time.Now()
|
|
result = CodexAppServerProbeResult{
|
|
AccountState: CodexAppServerAccountUnknown,
|
|
Category: CodexProbeUnknownRuntimeFail,
|
|
}
|
|
defer func() { result.Duration = time.Since(startedAt) }()
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if len(input.Command) == 0 || strings.TrimSpace(input.Command[0]) == "" {
|
|
result.CommandCategory = CodexProbeSpawnFailed
|
|
result.Category = CodexProbeSpawnFailed
|
|
result.Message = "app-server command is unavailable"
|
|
return result
|
|
}
|
|
transport := input.Transport
|
|
if transport == nil {
|
|
transport = NewLocalProcessTransport()
|
|
}
|
|
startupTimeout := input.StartupTimeout
|
|
if startupTimeout <= 0 {
|
|
startupTimeout = 3 * time.Second
|
|
}
|
|
startCtx, cancelStart := context.WithTimeout(ctx, startupTimeout)
|
|
defer cancelStart()
|
|
type startResult struct {
|
|
conn ProcessConnection
|
|
err error
|
|
}
|
|
started := make(chan startResult, 1)
|
|
go func() {
|
|
conn, err := transport.Start(startCtx, ProcessSpec{
|
|
Provider: "codex", Command: append([]string(nil), input.Command...),
|
|
Env: append([]string(nil), input.Env...), CWD: input.CWD,
|
|
})
|
|
started <- startResult{conn: conn, err: err}
|
|
}()
|
|
var launch startResult
|
|
select {
|
|
case launch = <-started:
|
|
case <-startCtx.Done():
|
|
result.CommandCategory = CodexProbeStartupTimeout
|
|
if errors.Is(startCtx.Err(), context.Canceled) {
|
|
result.CommandCategory = CodexProbeCanceled
|
|
}
|
|
result.Category = result.CommandCategory
|
|
result.Message = startCtx.Err().Error()
|
|
// A conforming transport observes startCtx. If one returns a connection
|
|
// late, reclaim it instead of leaving a process behind.
|
|
go func() {
|
|
late := <-started
|
|
if late.conn != nil {
|
|
closeCodexProbeConnection(late.conn, input.ShutdownTimeout)
|
|
}
|
|
}()
|
|
return result
|
|
}
|
|
conn, err := launch.conn, launch.err
|
|
if err != nil {
|
|
result.CommandCategory = CodexProbeSpawnFailed
|
|
result.Category = CodexProbeSpawnFailed
|
|
result.Message = err.Error()
|
|
return result
|
|
}
|
|
result.CommandStarted = true
|
|
var client *codexAppServerClient
|
|
defer func() {
|
|
closeCodexProbeConnection(conn, input.ShutdownTimeout)
|
|
if client == nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-client.Done():
|
|
case <-time.After(min(nonZeroDuration(input.ShutdownTimeout, 100*time.Millisecond), 100*time.Millisecond)):
|
|
}
|
|
diagnostics := client.Diagnostics()
|
|
result.StderrTail = diagnostics.StderrTail
|
|
}()
|
|
|
|
handshakeTimeout := input.HandshakeTimeout
|
|
if handshakeTimeout <= 0 {
|
|
handshakeTimeout = 3 * time.Second
|
|
}
|
|
handshakeCtx, cancelHandshake := context.WithTimeout(ctx, handshakeTimeout)
|
|
defer cancelHandshake()
|
|
client = newCodexAppServerClient(conn)
|
|
// A probe must prove that the server read this specific initialize request.
|
|
// Keep normal session IDs deterministic, but seed this one-shot client so a
|
|
// launcher that prints a canned response for id 1 cannot appear healthy.
|
|
client.raw.nextID.Store(rand.Int64N(1_000_000) + 1)
|
|
client.SetMessageHandler(func(context.Context, acpMessage) error { return nil })
|
|
host := normalizeHostMetadata(input.Host)
|
|
_, err = client.Initialize(handshakeCtx, handshakeTimeout, map[string]any{
|
|
"clientInfo": host.clientInfoParams(),
|
|
"capabilities": map[string]any{"experimentalApi": true},
|
|
}, func(context.Context, acpMessage) error { return nil })
|
|
if err != nil {
|
|
result.ProtocolCategory = codexProbeErrorCategory(handshakeCtx, err)
|
|
result.Category = result.ProtocolCategory
|
|
result.Message = err.Error()
|
|
return result
|
|
}
|
|
if err := client.Initialized(handshakeCtx); err != nil {
|
|
result.ProtocolCategory = codexProbeErrorCategory(handshakeCtx, err)
|
|
result.Category = result.ProtocolCategory
|
|
result.Message = err.Error()
|
|
return result
|
|
}
|
|
result.ProtocolReady = true
|
|
// Keep this result invariant explicit for callers which only retain the
|
|
// final protocol fact.
|
|
result.CommandStarted = true
|
|
result.Category = ""
|
|
if input.ReadAccount {
|
|
accountRaw, err := client.AccountRead(handshakeCtx, handshakeTimeout, map[string]any{},
|
|
func(context.Context, acpMessage) error { return nil })
|
|
if err != nil {
|
|
result.AccountCategory = codexProbeErrorCategory(handshakeCtx, err)
|
|
result.Category = result.AccountCategory
|
|
result.Message = err.Error()
|
|
return result
|
|
}
|
|
account, ok := parseCodexProbeAccount(accountRaw)
|
|
if !ok {
|
|
result.AccountCategory = CodexProbeInvalidResponse
|
|
result.Category = result.AccountCategory
|
|
result.Message = "account/read returned an invalid account response"
|
|
return result
|
|
}
|
|
result.AccountRead = true
|
|
result.AccountState = account.State
|
|
result.AccountLabel = account.Label
|
|
result.AuthMethod = account.AuthMethod
|
|
}
|
|
if input.ReadRateLimits {
|
|
response, err := client.AccountRateLimitsRead(
|
|
handshakeCtx,
|
|
handshakeTimeout,
|
|
func(context.Context, acpMessage) error { return nil },
|
|
)
|
|
if err != nil {
|
|
result.RateLimitsCategory = codexProbeErrorCategory(handshakeCtx, err)
|
|
result.Category = result.RateLimitsCategory
|
|
result.Message = err.Error()
|
|
return result
|
|
}
|
|
if response == nil {
|
|
result.RateLimitsCategory = CodexProbeInvalidResponse
|
|
result.Category = result.RateLimitsCategory
|
|
result.Message = "account/rateLimits/read returned an empty response"
|
|
return result
|
|
}
|
|
if json.Unmarshal(response, &result.RateLimits) != nil || result.RateLimits == nil {
|
|
result.RateLimitsCategory = CodexProbeInvalidResponse
|
|
result.Category = result.RateLimitsCategory
|
|
result.Message = "account/rateLimits/read returned an invalid response"
|
|
return result
|
|
}
|
|
result.RateLimitsRead = true
|
|
}
|
|
return result
|
|
}
|
|
|
|
type codexProbeAccount struct {
|
|
State CodexAppServerAccountState
|
|
Label string
|
|
AuthMethod string
|
|
}
|
|
|
|
func parseCodexProbeAccount(raw json.RawMessage) (codexProbeAccount, bool) {
|
|
var payload struct {
|
|
Account map[string]any `json:"account"`
|
|
RequiresOpenaiAuth *bool `json:"requiresOpenaiAuth"`
|
|
}
|
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.RequiresOpenaiAuth == nil {
|
|
return codexProbeAccount{}, false
|
|
}
|
|
if payload.Account == nil {
|
|
if *payload.RequiresOpenaiAuth {
|
|
return codexProbeAccount{State: CodexAppServerAccountRequired}, true
|
|
}
|
|
return codexProbeAccount{State: CodexAppServerAccountUnknown}, true
|
|
}
|
|
authMethod := strings.TrimSpace(asString(payload.Account["type"]))
|
|
switch authMethod {
|
|
case "chatgpt":
|
|
if strings.TrimSpace(asString(payload.Account["planType"])) == "" {
|
|
return codexProbeAccount{}, false
|
|
}
|
|
case "apiKey", "amazonBedrock":
|
|
default:
|
|
return codexProbeAccount{}, false
|
|
}
|
|
return codexProbeAccount{
|
|
State: CodexAppServerAccountAuthenticated,
|
|
Label: strings.TrimSpace(asString(payload.Account["email"])),
|
|
AuthMethod: authMethod,
|
|
}, true
|
|
}
|
|
|
|
func closeCodexProbeConnection(conn ProcessConnection, _ time.Duration) {
|
|
if conn == nil {
|
|
return
|
|
}
|
|
_ = conn.Close()
|
|
}
|
|
|
|
func nonZeroDuration(value, fallback time.Duration) time.Duration {
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func codexProbeErrorCategory(ctx context.Context, err error) string {
|
|
if errors.Is(ctx.Err(), context.Canceled) {
|
|
return CodexProbeCanceled
|
|
}
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
return CodexProbeHandshakeTimeout
|
|
}
|
|
message := strings.ToLower(fmt.Sprint(err))
|
|
switch {
|
|
case strings.Contains(message, "unexpected end"), strings.Contains(message, "eof"), strings.Contains(message, "closed"):
|
|
return CodexProbeStdioClosed
|
|
case strings.Contains(message, "invalid character"), strings.Contains(message, "json"),
|
|
strings.Contains(message, "invalid") && strings.Contains(message, "stdout"):
|
|
return CodexProbeInvalidResponse
|
|
default:
|
|
return CodexProbeProtocolFailure
|
|
}
|
|
}
|