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
20 changes: 11 additions & 9 deletions cmd/entire/cli/api/trails.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,8 @@ import (
"net/url"
)

// TrailsEnabled reports whether the trails feature is enabled for the repo on
// the API. It probes the trails list endpoint (limit=1): a 2xx response means
// trails are provisioned/enabled for the repo, while 404/403 (and any other
// non-2xx) mean they are not enabled or not accessible to this caller.
//
// Transport errors are returned to the caller (with enabled=false) so a
// "couldn't reach the API" outcome is distinguishable from a definitive
// "not enabled".
// TrailsEnabled probes trail availability: 2xx=true, 403/404/410=false,
// everything else ambiguous.
func (c *Client) TrailsEnabled(ctx context.Context, forge, owner, repo string) (bool, error) {
resp, err := c.Get(ctx, fmt.Sprintf("/api/v1/trails/%s/%s/%s?limit=1",
url.PathEscape(forge), url.PathEscape(owner), url.PathEscape(repo)))
Expand All @@ -25,5 +19,13 @@ func (c *Client) TrailsEnabled(ctx context.Context, forge, owner, repo string) (
defer resp.Body.Close()
// Drain (bounded) so net/http can reuse the connection; the body is unused.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) //nolint:errcheck // best-effort drain
return resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices, nil
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return true, nil
}
switch resp.StatusCode {
case http.StatusForbidden, http.StatusNotFound, http.StatusGone:
return false, nil
default:
return false, fmt.Errorf("probe trails enablement: unexpected status %s", resp.Status)
}
}
4 changes: 3 additions & 1 deletion cmd/entire/cli/api/trails_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ func TestClient_TrailsEnabled(t *testing.T) {
{"enabled empty (200)", http.StatusOK, `{"trails":[]}`, true, true},
{"not enabled (404)", http.StatusNotFound, `{"error":"not found"}`, false, true},
{"forbidden (403)", http.StatusForbidden, `{"error":"forbidden"}`, false, true},
{"server error (500)", http.StatusInternalServerError, `{"error":"boom"}`, false, true},
{"gone (410)", http.StatusGone, `{"error":"gone"}`, false, true},
{"unauthorized (401)", http.StatusUnauthorized, `{"error":"unauthorized"}`, false, false},
{"server error (500)", http.StatusInternalServerError, `{"error":"boom"}`, false, false},
}

for _, tt := range tests {
Expand Down
33 changes: 33 additions & 0 deletions cmd/entire/cli/auth/contexts.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"

Expand Down Expand Up @@ -147,6 +148,38 @@ func sameIssuer(a, b string) bool {
return strings.TrimRight(a, "/") == strings.TrimRight(b, "/")
}

// LocalIdentityCacheKey returns a non-secret local auth identity key.
func LocalIdentityCacheKey() (string, error) {
if raw := strings.TrimSpace(os.Getenv(EnvTokenVar)); raw != "" {
claims, err := tokens.ParseClaims(raw)
if err != nil {
return "", fmt.Errorf("parse %s claims: %w", EnvTokenVar, err)
}
return strings.Join([]string{
"env",
strings.TrimRight(claims.Issuer, "/"),
claims.Subject,
claims.Handle,
strings.Join(claims.Audience, ","),
}, "|"), nil
}

c, ok, err := activeContext()
if err != nil {
return "", err
}
if !ok {
return "", nil
}
return strings.Join([]string{
"context",
strings.TrimRight(c.CoreURL, "/"),
c.Name,
c.Handle,
c.KeychainService,
}, "|"), nil
}

// LoginTokenForContext returns the login JWT stored for c, read from the
// OS keyring slot the context points at. The encoded expiry is stripped;
// the server is the authority on validity and the device-flow login holds
Expand Down
43 changes: 43 additions & 0 deletions cmd/entire/cli/auth/contexts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"

Expand All @@ -27,6 +28,48 @@ func makeJWT(t *testing.T, payloadJSON string) string {
return header + "." + payload + "." + enc.EncodeToString([]byte("sig"))
}

func TestLocalIdentityCacheKey_ActiveContext(t *testing.T) {
cfgDir := t.TempDir()
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
t.Setenv(EnvTokenVar, "")

ctx := &contexts.Context{
Name: "alice@core",
CoreURL: "https://core.example.com/",
Handle: "alice",
KeychainService: "entire-core:https://core.example.com",
}
if err := contexts.Save(cfgDir, &contexts.File{CurrentContext: ctx.Name, Contexts: []*contexts.Context{ctx}}); err != nil {
t.Fatalf("save contexts: %v", err)
}

got, err := LocalIdentityCacheKey()
if err != nil {
t.Fatalf("LocalIdentityCacheKey: %v", err)
}
want := "context|https://core.example.com|alice@core|alice|entire-core:https://core.example.com"
if got != want {
t.Fatalf("cache key = %q, want %q", got, want)
}
}

func TestLocalIdentityCacheKey_EnvToken(t *testing.T) {
token := makeJWT(t, `{"iss":"https://core.example.com/","sub":"svc-1","handle":"robot","aud":"https://api.example.com"}`)
t.Setenv(EnvTokenVar, token)

got, err := LocalIdentityCacheKey()
if err != nil {
t.Fatalf("LocalIdentityCacheKey: %v", err)
}
want := "env|https://core.example.com|svc-1|robot|https://api.example.com"
if got != want {
t.Fatalf("cache key = %q, want %q", got, want)
}
if strings.Contains(got, token) {
t.Fatalf("cache key appears to contain raw JWT material: %q", got)
}
}

// RecordLoginContext must persist the refresh token before the access token,
// so a failed access write never commits a fresh access JWT against a stale
// refresh token left over from an earlier login.
Expand Down
45 changes: 33 additions & 12 deletions cmd/entire/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ func handleLifecycleSessionStart(ctx context.Context, ag agent.Agent, event *age
slog.String("error", hintErr.Error()))
}

// Resolve scope before the TurnStart prompt path.
refreshCtx, refreshCancel := context.WithTimeout(ctx, trailEnablementSessionStartRefreshTimeout)
if scope, scopeErr := currentTrailEnablementScope(refreshCtx); scopeErr != nil {
logging.Debug(logCtx, "trails enablement refresh skipped",
slog.String("error", scopeErr.Error()))
} else {
if hintErr := saveTrailEnablementScopeHint(ctx, event.SessionID, scope); hintErr != nil {
logging.Debug(logCtx, "failed to cache trails scope hint",
slog.String("error", hintErr.Error()))
}
if refreshErr := refreshTrailsEnabledCacheIfStaleForScope(refreshCtx, scope); refreshErr != nil {
logging.Debug(logCtx, "trails enablement refresh skipped",
slog.String("error", refreshErr.Error()))
}
}
refreshCancel()

// Build informational message — warn early if repo has no commits yet,
// since checkpoints require at least one commit to work.
message := sessionStartMessage(ag.Name(), false)
Expand Down Expand Up @@ -384,12 +401,14 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even
}
logCtx := logging.WithAgent(logging.WithComponent(ctx, "lifecycle"), ag.Name())

// Decide once per session, recorded on the session state itself (not a
// separate marker file). Winning the check-and-set means this turn owns the
// decision. trailsEnabledForRepo only reads clone-local cached enablement;
// the API refresh happens earlier on `entire enable`, outside the prompt path.
// Marking "decided" before checking the cache means a missing/stale false
// cache fails closed (no hint for this session) rather than retrying/spamming.
// Unknown cache leaves the session retryable.
scope, scopeOK, scopeErr := loadTrailEnablementScopeHint(ctx, event.SessionID)
if scopeErr != nil {
logging.Warn(logCtx, "failed to load trails scope hint",
slog.String("error", scopeErr.Error()))
return
}
decision := trailEnablementCacheUnknown
mutated := false
mutErr := strategy.MutateSessionState(ctx, event.SessionID, func(state *strategy.SessionState) error {
if state.ContextInjectionDecided {
Expand All @@ -401,6 +420,13 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even
if state.Kind != "" {
return strategy.ErrMutationSkip
}
if !scopeOK {
return strategy.ErrMutationSkip
}
decision = cachedTrailsEnablementForScope(ctx, scope, time.Now())
if decision == trailEnablementCacheUnknown {
return strategy.ErrMutationSkip
}
state.ContextInjectionDecided = true
mutated = true
return nil
Expand All @@ -414,12 +440,7 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even
// state failed, mutErr was non-nil above and we returned without injecting,
// leaving a later turn free to retry safely.
won := mutErr == nil && mutated
if !won {
return // already decided for this session, skipped kind, or no session state yet
}

// Only advertise trails when they're literally enabled for this repo on the API.
if !trailsEnabledForRepo(ctx) {
if !won || decision != trailEnablementCacheEnabled {
return
}

Expand Down
73 changes: 73 additions & 0 deletions cmd/entire/cli/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -1140,6 +1141,78 @@ func TestHandleLifecycleTurnStart_WritesPromptContent(t *testing.T) {
}
}

type mockContextInjectorAgent struct {
mockLifecycleAgent
}

var _ agent.ContextInjector = (*mockContextInjectorAgent)(nil)

func (m *mockContextInjectorAgent) InjectionEvent() agent.EventType { return agent.TurnStart }

func (m *mockContextInjectorAgent) RenderContextInjection(agent.ContextInjection) ([]byte, error) {
return nil, nil
}

func addGitHubOriginForLifecycleTest(t *testing.T, repoDir string) {
t.Helper()
cmd := exec.CommandContext(context.Background(), "git", "remote", "add", "origin", "git@github.com:acme/repo.git")
cmd.Dir = repoDir
cmd.Env = testutil.GitIsolatedEnv()
require.NoError(t, cmd.Run())
}

func TestHandleLifecycleTurnStart_ContextInjectionUnknownCacheDoesNotMarkDecided(t *testing.T) {
// Cannot use t.Parallel() because we use t.Chdir().
tmpDir := t.TempDir()
testutil.InitRepo(t, tmpDir)
testutil.WriteFile(t, tmpDir, "init.txt", "init")
testutil.GitAdd(t, tmpDir, "init.txt")
testutil.GitCommit(t, tmpDir, "init")
addGitHubOriginForLifecycleTest(t, tmpDir)
t.Chdir(tmpDir)
paths.ClearWorktreeRootCache()
session.ClearGitCommonDirCache()

ag := &mockContextInjectorAgent{mockLifecycleAgent: *newMockAgent()}
sessionID := "test-trail-inject-unknown"
event := &agent.Event{Type: agent.TurnStart, SessionID: sessionID, Prompt: "hello", Timestamp: time.Now()}

require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event))

state, err := strategy.LoadSessionState(context.Background(), sessionID)
require.NoError(t, err)
require.NotNil(t, state)
require.False(t, state.ContextInjectionDecided, "unknown/missing cache should not permanently suppress later injection")
}

func TestHandleLifecycleTurnStart_ContextInjectionFreshTrueMarksDecided(t *testing.T) {
// Cannot use t.Parallel() because we use t.Chdir().
tmpDir := t.TempDir()
testutil.InitRepo(t, tmpDir)
testutil.WriteFile(t, tmpDir, "init.txt", "init")
testutil.GitAdd(t, tmpDir, "init.txt")
testutil.GitCommit(t, tmpDir, "init")
addGitHubOriginForLifecycleTest(t, tmpDir)
t.Chdir(tmpDir)
paths.ClearWorktreeRootCache()
session.ClearGitCommonDirCache()
require.NoError(t, saveTrailsEnabledForRepo(context.Background(), true))

ag := &mockContextInjectorAgent{mockLifecycleAgent: *newMockAgent()}
sessionID := "test-trail-inject-true"
scope, err := currentTrailEnablementScope(context.Background())
require.NoError(t, err)
require.NoError(t, saveTrailEnablementScopeHint(context.Background(), sessionID, scope))
event := &agent.Event{Type: agent.TurnStart, SessionID: sessionID, Prompt: "hello", Timestamp: time.Now()}

require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event))

state, err := strategy.LoadSessionState(context.Background(), sessionID)
require.NoError(t, err)
require.NotNil(t, state)
require.True(t, state.ContextInjectionDecided, "fresh true cache should make a final injection decision")
}

func TestHandleLifecycleTurnStart_RecordsGenericSkillSlashEvent(t *testing.T) {
// Cannot use t.Parallel() because we use t.Chdir()
tmpDir := t.TempDir()
Expand Down
Loading
Loading