Why: F02/F04/F15 - contract requests shared one transcript across session scopes, Writable was dropped, and a torn JSONL tail poisoned the next append; pure-text contract results lost their text. Evidence: adapters/native, runtime/session, runtime/tools and conformance tests pass (verify-u2.json); scope-isolation, write-set and torn-tail regression cases added. Impact: empty scope keeps legacy one-shot semantics; identity-less old transcripts are never auto-adopted by a new scope. Refs: feature/agent-stack-audit-remediation-b1 U2
92 lines
2.3 KiB
Go
92 lines
2.3 KiB
Go
package native
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"agent-native/exec"
|
|
)
|
|
|
|
// Contract-binding context keys: Writable / CapText / empty-target semantics
|
|
// stay inside the adapter so exec.Request remains unchanged for the legacy facade.
|
|
|
|
type ctxKey int
|
|
|
|
const (
|
|
keyWritable ctxKey = iota
|
|
keyAllowEmptyTargets
|
|
keyTextSink
|
|
)
|
|
|
|
func withWritable(ctx context.Context, paths []string) context.Context {
|
|
if len(paths) == 0 {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, keyWritable, append([]string(nil), paths...))
|
|
}
|
|
|
|
func writableFrom(ctx context.Context) []string {
|
|
v, _ := ctx.Value(keyWritable).([]string)
|
|
return v
|
|
}
|
|
|
|
func withAllowEmptyTargets(ctx context.Context) context.Context {
|
|
return context.WithValue(ctx, keyAllowEmptyTargets, true)
|
|
}
|
|
|
|
func allowEmptyTargets(ctx context.Context) bool {
|
|
v, _ := ctx.Value(keyAllowEmptyTargets).(bool)
|
|
return v
|
|
}
|
|
|
|
func withTextSink(ctx context.Context, sink *string) context.Context {
|
|
if sink == nil {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, keyTextSink, sink)
|
|
}
|
|
|
|
func captureText(ctx context.Context, text string) {
|
|
if sink, ok := ctx.Value(keyTextSink).(*string); ok && sink != nil {
|
|
*sink = text
|
|
}
|
|
}
|
|
|
|
// scopeIdentity returns a stable non-empty identity string for a non-zero
|
|
// SessionScope, or "" for the legacy one-shot / empty-scope path.
|
|
func scopeIdentity(s exec.SessionScope) string {
|
|
t := strings.TrimSpace(s.Tenant)
|
|
p := strings.TrimSpace(s.Project)
|
|
sess := strings.TrimSpace(s.Session)
|
|
if t == "" && p == "" && sess == "" {
|
|
return ""
|
|
}
|
|
return t + "/" + p + "/" + sess
|
|
}
|
|
|
|
// transcriptPathForScope binds storage identity to SessionScope.
|
|
// Empty scope keeps the historical fixed path under .agentx/transcript.jsonl.
|
|
func transcriptPathForScope(workDir, scopeID string) string {
|
|
if strings.TrimSpace(scopeID) == "" {
|
|
return filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
|
}
|
|
sum := sha256.Sum256([]byte(scopeID))
|
|
return filepath.Join(workDir, ".agentx", "transcript."+hex.EncodeToString(sum[:16])+".jsonl")
|
|
}
|
|
|
|
func pathInWriteSet(allow []string, rel string) bool {
|
|
if len(allow) == 0 {
|
|
return true
|
|
}
|
|
rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
|
|
for _, a := range allow {
|
|
if filepath.ToSlash(filepath.Clean(strings.TrimSpace(a))) == rel {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|