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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions internal/agent/auth_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ func usableProtocolCredential(
return dpopCredential{}, errors.New("Realmroot Agent protocol OAuth credential is unavailable")
}
protocol := *state.ProtocolCredential
if protocol.AccessToken != "" && protocol.ExpiresAt != nil && time.Now().Add(5*time.Second).Before(*protocol.ExpiresAt) {
if protocol.AccessToken != "" && protocol.ExpiresAt != nil && time.Now().Add(5*time.Second).Before(*protocol.ExpiresAt) &&
credentialMatchesAgentSession(state, protocol) {
return protocol, nil
}
if len(protocol.Scopes) == 0 {
Expand Down Expand Up @@ -429,7 +430,8 @@ func ensureProtocolCredential(
}
if credential.AccessToken == "" || credential.ExpiresAt == nil ||
!time.Now().Add(5*time.Second).Before(*credential.ExpiresAt) ||
!scopesContain(credential.Scopes, requiredScopes) {
!scopesContain(credential.Scopes, requiredScopes) ||
!credentialMatchesAgentSession(state, *credential) {
requestedScopes := retainedBootstrapScopes(credential.Scopes, requiredScopes, configuration.AgentBootstrapScopes)
updated, err := requestProtocolToken(ctx, client, state, *credential, configuration, requestedScopes)
if err != nil {
Expand Down Expand Up @@ -492,9 +494,15 @@ func requestProtocolToken(
expiresAt := time.Now().Add(time.Duration(response.ExpiresIn) * time.Second)
credential.ExpiresAt = &expiresAt
credential.Scopes = append([]string(nil), requiredScopes...)
credential.RuntimeSessionID, _ = agentSession(state.Runtime)
return credential, nil
}

func credentialMatchesAgentSession(state agentState, credential dpopCredential) bool {
sessionID, _ := agentSession(state.Runtime)
return credential.RuntimeSessionID == sessionID
}

func sameOrigin(value string, origin string) bool {
endpoint, err := url.Parse(value)
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
Expand Down
6 changes: 4 additions & 2 deletions internal/agent/credential_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ func ensureInternalProtocolCredential(
}
if credential.AccessToken == "" || credential.ExpiresAt == nil ||
!time.Now().Add(5*time.Second).Before(*credential.ExpiresAt) ||
!scopesContain(credential.Scopes, requiredScopes) {
!scopesContain(credential.Scopes, requiredScopes) ||
!credentialMatchesAgentSession(reference.state, *credential) {
requestedScopes := retainedBootstrapScopes(credential.Scopes, requiredScopes, configuration.AgentBootstrapScopes)
updated, err := requestProtocolToken(
ctx, client, reference.state, *credential, configuration, requestedScopes,
Expand Down Expand Up @@ -452,7 +453,8 @@ func reusableInternalProtocolCredential(state agentState, requiredScopes []strin
}
credential := *state.ProtocolCredential
return credential, credential.AccessToken != "" && credential.ExpiresAt != nil &&
time.Now().Add(5*time.Second).Before(*credential.ExpiresAt) && scopesContain(credential.Scopes, requiredScopes)
time.Now().Add(5*time.Second).Before(*credential.ExpiresAt) && scopesContain(credential.Scopes, requiredScopes) &&
credentialMatchesAgentSession(state, credential)
}

func realmrootDPoPNonceChallenge(err error) (*dpopNonceChallenge, bool) {
Expand Down
33 changes: 33 additions & 0 deletions internal/agent/credential_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,39 @@ func TestInternalProtocolCredentialRetainsPreviouslyIssuedBootstrapScopes(t *tes
}
}

func TestInternalProtocolCredentialRefreshesWhenRuntimeSessionChanges(t *testing.T) {
t.Setenv("CODEX_THREAD_ID", "session-new")
offer := testCredential(t, "", time.Time{})
states := newCredentialState(t, offer)
states.state.Runtime = "codex"
states.state.ProtocolCredential.RuntimeSessionID = "session-old"
tokenRequests := 0
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
tokenRequests++
return jsonResponse(http.StatusOK, map[string]any{
"access_token": "session-token", "token_type": "DPoP", "expires_in": 300,
}), nil
})
configuration := agentConfiguration{
AgentTokenEndpoint: "https://auth.example.com/api/auth/oauth2/token",
AgentBootstrapScopes: []string{"agent:read", "access-requests:read", "access-requests:write"},
}
reference := credentialSourceStateReference{
path: "memory", state: states.state, reference: testCredentialSourceReference,
source: states.state.CredentialSources[testCredentialSourceReference],
}

credential, err := ensureInternalProtocolCredential(
context.Background(), client, states, reference, configuration, []string{"agent:read"},
)
if err != nil {
t.Fatal(err)
}
if tokenRequests != 1 || credential.RuntimeSessionID != "session-new" || credential.AccessToken != "session-token" {
t.Fatalf("token requests = %d, credential = %#v", tokenRequests, credential)
}
}

func TestCredentialSourceDescribesStoredOfferWithoutCredentialMaterial(t *testing.T) {
offer := testCredential(t, "", time.Time{})
states := newCredentialState(t, offer)
Expand Down
15 changes: 13 additions & 2 deletions internal/agent/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"time"
)

const realmrootAgentBindingClaim = "urn:realmroot:params:agent:binding"

func newSigningKey(prefix string) (ed25519.PublicKey, ed25519.PrivateKey, string, error) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
Expand Down Expand Up @@ -54,11 +56,20 @@ func signAgentJWT(state agentState, issuer string, now time.Time) (string, error
if err != nil {
return "", err
}
return signJWT(privateKey, state.AgentKeyID, "agent+jwt", map[string]any{
claims := map[string]any{
"iss": state.HostID,
"sub": state.AgentID,
"aud": issuer,
}, now)
}
if sessionID, ok := agentSession(state.Runtime); ok {
claims[realmrootAgentBindingClaim] = map[string]string{
"protocol_agent_id": state.AgentID,
"host_id": state.HostID,
"runtime": state.Runtime,
"session_id": sessionID,
}
}
return signJWT(privateKey, state.AgentKeyID, "agent+jwt", claims, now)
}

func signHostJWT(state hostState, issuer string, now time.Time) (string, error) {
Expand Down
7 changes: 7 additions & 0 deletions internal/agent/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
)

func TestSignAgentJWTProducesVerifiablePossessionProof(t *testing.T) {
t.Setenv("CODEX_THREAD_ID", "thread-raw-123")
publicKey, privateKey, keyID, err := newSigningKey("agent")
if err != nil {
t.Fatal(err)
Expand All @@ -21,6 +22,7 @@ func TestSignAgentJWTProducesVerifiablePossessionProof(t *testing.T) {
HostID: "host-123",
AgentKeyID: keyID,
AgentPrivateKey: encodePrivateKey(privateKey),
Runtime: "codex",
}

token, err := signAgentJWT(state, "https://auth.example.com/api/auth", now)
Expand Down Expand Up @@ -52,6 +54,11 @@ func TestSignAgentJWTProducesVerifiablePossessionProof(t *testing.T) {
if claims["aud"] != "https://auth.example.com/api/auth" {
t.Fatalf("unexpected JWT audience: %#v", claims["aud"])
}
if binding, ok := claims[realmrootAgentBindingClaim].(map[string]any); !ok ||
binding["protocol_agent_id"] != "agent-123" || binding["host_id"] != "host-123" ||
binding["runtime"] != "codex" || binding["session_id"] != "thread-raw-123" {
t.Fatalf("unexpected Agent runtime session binding: %#v", claims[realmrootAgentBindingClaim])
}
if claims["iat"] != float64(now.Unix()) || claims["exp"] != float64(now.Add(2*time.Minute).Unix()) {
t.Fatalf("unexpected JWT lifetime: %#v", claims)
}
Expand Down
51 changes: 35 additions & 16 deletions internal/agent/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ const (
defaultAgentRuntimeDisplayName = "Realmroot Toolbox"
)

var sessionEnvironmentNames = []string{
"AGENT_SESSION_ID",
"CODEX_THREAD_ID",
"HERMES_SESSION_KEY",
var sessionEnvironmentNames = map[string][]string{
"claude": {"CLAUDE_CODE_SESSION_ID"},
"codex": {"CODEX_THREAD_ID"},
"copilot": {"COPILOT_AGENT_SESSION_ID"},
"goose": {"AGENT_SESSION_ID"},
"hermes": {"HERMES_SESSION_ID", "HERMES_SESSION_KEY"},
"pi": {"PI_SESSION_ID"},
}

type environmentLookup func(string) (string, bool)
Expand All @@ -34,30 +37,46 @@ var runtimeDetectors = []runtimeDetector{
{name: "qwen", displayName: "Qwen", matches: hasEnvironment("QWEN_CODE")},
{name: "cursor", displayName: "Cursor", matches: hasEnvironment("CURSOR_AGENT")},
{name: "kiro", displayName: "Kiro", matches: hasEnvironments("AGENT_DISPLAY_OUT", "AGENT_CONTEXT_OUT")},
{name: "pi", displayName: "Pi", matches: hasEnvironment("PI_CODING_AGENT")},
{name: "codex", displayName: "Codex", matches: hasEnvironment("CODEX_CI")},
{name: "copilot", displayName: "Copilot", matches: hasEnvironment("COPILOT_CLI")},
{name: "pi", displayName: "Pi", matches: hasAnyEnvironment("PI_CODING_AGENT", "PI_SESSION_ID")},
{name: "codex", displayName: "Codex", matches: hasAnyEnvironment("CODEX_CI", "CODEX_THREAD_ID")},
{name: "copilot", displayName: "Copilot", matches: hasAnyEnvironment("COPILOT_CLI", "COPILOT_AGENT_SESSION_ID")},
{name: "gemini", displayName: "Gemini", matches: hasEnvironment("GEMINI_CLI")},
{name: "claude", displayName: "Claude", matches: hasEnvironment("CLAUDECODE")},
{name: "hermes", displayName: "Hermes", matches: hasAnyEnvironment("HERMES_INTERACTIVE", "HERMES_SESSION_KEY")},
{name: "claude", displayName: "Claude", matches: hasAnyEnvironment("CLAUDECODE", "CLAUDE_CODE_SESSION_ID")},
{name: "hermes", displayName: "Hermes", matches: hasAnyEnvironment("HERMES_INTERACTIVE", "HERMES_SESSION_ID", "HERMES_SESSION_KEY")},
}

func agentRuntime() (string, error) {
return detectAgentRuntime(os.LookupEnv)
}

func agentSession() string {
return detectAgentSession(os.LookupEnv)
func agentSession(runtime string) (string, bool) {
return detectAgentSession(runtime, os.LookupEnv)
}

func detectAgentSession(lookup environmentLookup) string {
for _, name := range sessionEnvironmentNames {
func AgentSessionCacheKey() (string, error) {
runtime, err := agentRuntime()
if err != nil {
return "", err
}
sessionID, ok := agentSession(runtime)
if !ok {
return runtime + "-none", nil
}
digest := sha256.Sum256([]byte(runtime + "\x00" + sessionID))
return runtime + "-" + hex.EncodeToString(digest[:16]), nil
}

func detectAgentSession(runtime string, lookup environmentLookup) (string, bool) {
names := sessionEnvironmentNames[runtime]
if len(names) == 0 {
names = []string{"AGENT_SESSION_ID"}
}
for _, name := range names {
if value, ok := lookup(name); ok && strings.TrimSpace(value) != "" {
digest := sha256.Sum256([]byte(name + "\x00" + value))
return strings.ToLower(name) + ":" + hex.EncodeToString(digest[:16])
return value, true
}
}
return "default"
return "", false
}

func detectAgentRuntime(lookup environmentLookup) (string, error) {
Expand Down
53 changes: 39 additions & 14 deletions internal/agent/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ func TestDetectAgentRuntimeRecognizesAgentTools(t *testing.T) {
{environment: map[string]string{"CURSOR_AGENT": ""}, expected: "cursor"},
{environment: map[string]string{"AGENT_DISPLAY_OUT": "", "AGENT_CONTEXT_OUT": ""}, expected: "kiro"},
{environment: map[string]string{"PI_CODING_AGENT": ""}, expected: "pi"},
{environment: map[string]string{"CODEX_CI": ""}, expected: "codex"},
{environment: map[string]string{"COPILOT_CLI": ""}, expected: "copilot"},
{environment: map[string]string{"CODEX_THREAD_ID": "thread-1"}, expected: "codex"},
{environment: map[string]string{"COPILOT_AGENT_SESSION_ID": "session-1"}, expected: "copilot"},
{environment: map[string]string{"GEMINI_CLI": ""}, expected: "gemini"},
{environment: map[string]string{"CLAUDECODE": ""}, expected: "claude"},
{environment: map[string]string{"HERMES_SESSION_KEY": ""}, expected: "hermes"},
{environment: map[string]string{"CLAUDE_CODE_SESSION_ID": "session-2"}, expected: "claude"},
{environment: map[string]string{"HERMES_SESSION_ID": "session-3"}, expected: "hermes"},
} {
runtime, err := detectAgentRuntime(testEnvironment(test.environment))
if err != nil {
Expand Down Expand Up @@ -84,18 +84,43 @@ func TestNormalizeDeviceDisplayNameRejectsMissingDeviceName(t *testing.T) {
}
}

func TestDetectAgentSessionIsolatesConcurrentSessionsWithoutPersistingRawIdentifiers(t *testing.T) {
first := detectAgentSession(testEnvironment(map[string]string{"CODEX_THREAD_ID": "thread-secret-1"}))
again := detectAgentSession(testEnvironment(map[string]string{"CODEX_THREAD_ID": "thread-secret-1"}))
second := detectAgentSession(testEnvironment(map[string]string{"CODEX_THREAD_ID": "thread-secret-2"}))
if first != again || first == second {
t.Fatalf("session keys are not stable and isolated: %q %q %q", first, again, second)
func TestDetectAgentSessionReturnsRawRuntimeIdentifier(t *testing.T) {
for _, test := range []struct {
runtime string
environment map[string]string
expected string
}{
{runtime: "codex", environment: map[string]string{"CODEX_THREAD_ID": "thread-1"}, expected: "thread-1"},
{runtime: "claude", environment: map[string]string{"CLAUDE_CODE_SESSION_ID": "session-2"}, expected: "session-2"},
{runtime: "copilot", environment: map[string]string{"COPILOT_AGENT_SESSION_ID": "session-3"}, expected: "session-3"},
{runtime: "goose", environment: map[string]string{"AGENT_SESSION_ID": "session-4"}, expected: "session-4"},
{runtime: "hermes", environment: map[string]string{"HERMES_SESSION_ID": "session-5"}, expected: "session-5"},
{runtime: "pi", environment: map[string]string{"PI_SESSION_ID": "session-6"}, expected: "session-6"},
} {
sessionID, ok := detectAgentSession(test.runtime, testEnvironment(test.environment))
if !ok || sessionID != test.expected {
t.Fatalf("runtime %q session = %q, %v", test.runtime, sessionID, ok)
}
}
if sessionID, ok := detectAgentSession("codex", testEnvironment(nil)); ok || sessionID != "" {
t.Fatalf("missing session = %q, %v", sessionID, ok)
}
if strings.Contains(first, "thread-secret") {
t.Fatalf("session key contains the raw external identifier: %q", first)
}

func TestAgentSessionCacheKeySeparatesRawSessionIdentifiers(t *testing.T) {
t.Setenv("AGENT", "codex")
t.Setenv("CODEX_THREAD_ID", "thread-secret-1")
first, err := AgentSessionCacheKey()
if err != nil {
t.Fatal(err)
}
t.Setenv("CODEX_THREAD_ID", "thread-secret-2")
second, err := AgentSessionCacheKey()
if err != nil {
t.Fatal(err)
}
if fallback := detectAgentSession(testEnvironment(nil)); fallback != "default" {
t.Fatalf("fallback session = %q", fallback)
if first == second || strings.Contains(first, "thread-secret") || strings.Contains(second, "thread-secret") {
t.Fatalf("session cache keys = %q, %q", first, second)
}
}

Expand Down
1 change: 1 addition & 0 deletions internal/agent/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type dpopCredential struct {
AccessToken string `json:"access_token,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Scopes []string `json:"scopes,omitempty"`
RuntimeSessionID string `json:"runtime_session_id,omitempty"`
}

type credentialSource struct {
Expand Down
43 changes: 39 additions & 4 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,11 @@ func TestPrepareOperationCredentialsBindsOpenAPICredentialID(t *testing.T) {
}
profile := config.APIs["github"].Profiles["default"]
credential := profile.Credentials["realmrootOidc"]
if profile.Auth != nil || credential == nil || credential.Auth == nil || credential.Auth.Type != "dpop" {
if credential == nil {
t.Fatalf("profile = %#v", profile)
}
auth := config.AuthProfiles[credential.AuthRef]
if profile.Auth != nil || credential.Auth != nil || auth == nil || auth.Type != "dpop" {
t.Fatalf("profile = %#v", profile)
}
if strings.Join(credential.Satisfies, " ") != "issues:read" {
Expand All @@ -418,7 +422,11 @@ func TestPrepareOperationCredentialsReplacesStaleProfileReference(t *testing.T)
t.Fatal(err)
}
credential := config.APIs["github"].Profiles["default"].Credentials["realmrootOidc"]
if credential == nil || credential.Auth == nil || credential.Auth.Params["reference"] != "selected-reference" {
if credential == nil {
t.Fatal("credential was not bound")
}
auth := config.AuthProfiles[credential.AuthRef]
if credential.Auth != nil || auth == nil || auth.Params["reference"] != "selected-reference" {
t.Fatalf("credential = %#v", credential)
}
}
Expand Down Expand Up @@ -514,11 +522,34 @@ func TestBindProfileCredentialsSupportsGenericHTTPRequests(t *testing.T) {
t.Fatal(err)
}
credential := config.APIs["platform"].Profiles["default"].Credentials["realmrootOidc"]
if credential == nil || credential.Auth == nil || credential.Auth.Type != "dpop" || credential.Auth.Params["reference"] != binding.Reference || strings.Join(credential.Satisfies, " ") != "resource-servers:read" {
if credential == nil {
t.Fatal("credential was not bound")
}
auth := config.AuthProfiles[credential.AuthRef]
if credential.Auth != nil || auth == nil || auth.Type != "dpop" || auth.Params["reference"] != binding.Reference || strings.Join(credential.Satisfies, " ") != "resource-servers:read" {
t.Fatalf("credential = %#v", credential)
}
}

func TestBindProfileCredentialsSeparatesRestishTokensByAgentSession(t *testing.T) {
t.Setenv("AGENT", "codex")
binding := agent.CredentialBinding{Reference: "selected-reference", Scopes: []string{"issues:read"}}
bind := func(sessionID string) string {
t.Helper()
t.Setenv("CODEX_THREAD_ID", sessionID)
config := &restish.Config{APIs: map[string]*restish.APIConfig{"github": {}}}
if err := bindProfileCredentials(config, catalog.ResourceServer{CommandName: "github"}, githubOperationInspection(), "default", binding); err != nil {
t.Fatal(err)
}
return config.APIs["github"].Profiles["default"].Credentials["realmrootOidc"].AuthRef
}
first := bind("thread-secret-1")
second := bind("thread-secret-2")
if first == second || strings.Contains(first, "thread-secret") || strings.Contains(second, "thread-secret") {
t.Fatalf("session auth references = %q, %q", first, second)
}
}

func TestOperationScopeAlternativesPreserveOAuthAlternatives(t *testing.T) {
operation := githubOperationInspection().Operations[0]
if got := operationCredentialScopeAlternatives(operation); len(got) != 2 || strings.Join(got[0], " ") != "issues:read" || strings.Join(got[1], " ") != "metadata:read" {
Expand Down Expand Up @@ -547,7 +578,11 @@ func TestOperationAuthoritySupportsStandardOpenIDKind(t *testing.T) {
t.Fatal(err)
}
credential := config.APIs["ama"].Profiles["default"].Credentials["realmrootOidc"]
if credential == nil || credential.Auth == nil || credential.Auth.Type != "dpop" {
if credential == nil {
t.Fatal("credential was not bound")
}
auth := config.AuthProfiles[credential.AuthRef]
if credential.Auth != nil || auth == nil || auth.Type != "dpop" {
t.Fatalf("credential = %#v", credential)
}
}
Expand Down
Loading