From c83c8885ebd6d003067b3dccd63725513de35f8c Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:30:57 -0400 Subject: [PATCH 1/9] agentdid: retain encrypted signing and encryption custody with lazy v1 documents --- .github/workflows/agent-identity.yml | 42 +++ cmd/codeaf/app_agent_identity_e2e_test.go | 327 ++++++++++++++++++ .../unreleased/0-agent-identity-custody.md | 18 + internal/agentdid/agentdid.go | 4 +- internal/agentdid/credentials.go | 297 ++++++++++++++++ internal/agentdid/credentials_test.go | 143 ++++++++ internal/agentdid/document.go | 142 ++++++++ internal/manual/chat/how-tasks-run.md | 15 +- internal/session/agent_identity.go | 91 ++++- .../session/agent_identity_custody_test.go | 194 +++++++++++ internal/session/place.go | 14 + internal/session/session.go | 5 +- 12 files changed, 1271 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/agent-identity.yml create mode 100644 cmd/codeaf/app_agent_identity_e2e_test.go create mode 100644 docs/changes/unreleased/0-agent-identity-custody.md create mode 100644 internal/agentdid/credentials.go create mode 100644 internal/agentdid/credentials_test.go create mode 100644 internal/agentdid/document.go create mode 100644 internal/session/agent_identity_custody_test.go diff --git a/.github/workflows/agent-identity.yml b/.github/workflows/agent-identity.yml new file mode 100644 index 0000000000..0ee91301f3 --- /dev/null +++ b/.github/workflows/agent-identity.yml @@ -0,0 +1,42 @@ +name: Agent identity acceptance + +on: + pull_request: + branches: [app/v3] + paths: + - internal/agentdid/** + - internal/session/agent_identity*.go + - internal/session/place.go + - internal/session/session.go + - cmd/codeaf/app_agent_identity_e2e_test.go + - .github/workflows/agent-identity.yml + +permissions: + contents: read + +concurrency: + group: agent-identity-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + acceptance: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build the actual app binary + run: make build + - name: Exercise the app as a desktop client + run: go test ./cmd/codeaf -run '^TestAppAgentIdentityBinaryE2E$' -count=1 -v -timeout 5m + - name: Race-check custody and lifecycle + run: go test -race ./internal/agentdid ./internal/session -run 'TestCredentials|TestCustody|TestOnlyTheRecipient|TestUnknownDocument|TestAgentIdentity|TestAgentDID|TestToolCommandsSeeTheAgentDID|TestTaskWorker.*DID' -count=1 -timeout 15m + - name: Run the repository PR gates against app/v3 + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: make pr-ready diff --git a/cmd/codeaf/app_agent_identity_e2e_test.go b/cmd/codeaf/app_agent_identity_e2e_test.go new file mode 100644 index 0000000000..846bacab2a --- /dev/null +++ b/cmd/codeaf/app_agent_identity_e2e_test.go @@ -0,0 +1,327 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/agentdid" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Run make build first. This drives the shipped stdio door in a fresh process, +// including a real shell tool, restart, and migration. Only the model is scripted. +func TestAppAgentIdentityBinaryE2E(t *testing.T) { + binary, err := filepath.Abs(filepath.Join("..", "..", "bin", "codeaf")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(binary); err != nil { + t.Skip("run make build to exercise the actual app binary") + } + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[]}`) + return + } + var body struct { + Stream bool `json:"stream"` + Tools []json.RawMessage + Messages []struct{ Role string } + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + needsTool := len(body.Tools) > 0 + for i := len(body.Messages) - 1; i >= 0; i-- { + if body.Messages[i].Role == "tool" { + needsTool = false + break + } + if body.Messages[i].Role == "user" { + break + } + } + delta := map[string]any{"content": "Identity QA complete"} + finish := "stop" + if needsTool { + args, _ := json.Marshal(map[string]string{"command": `printf '%s' "$CODEAF_AGENT_DID" > did.txt; env | sed -n '/^CODEAF_AGENT_/p' > identity-env.txt`}) + delta = map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": "identity-qa", "type": "function", "function": map[string]any{"name": "bash", "arguments": string(args)}}}} + finish = "tool_calls" + } + if body.Stream { + w.Header().Set("Content-Type", "text/event-stream") + chunk, _ := json.Marshal(map[string]any{"id": "qa", "choices": []any{map[string]any{"index": 0, "delta": delta}}}) + fmt.Fprintf(w, "data: %s\n\ndata: {\"id\":\"qa\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":%q}]}\n\ndata: [DONE]\n\n", chunk, finish) + return + } + delta["role"] = "assistant" + _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"index": 0, "message": delta, "finish_reason": finish}}}) + })) + defer model.Close() + root := resolvedTempDir(t) + appHome, workspace := filepath.Join(root, "state"), filepath.Join(root, "project") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + start := func() *identityQAProcess { + return startIdentityQAProcess(t, binary, root, appHome, workspace, model.URL) + } + p := start() + created := p.call("agents.create", map[string]any{"workspace": workspace, "model": "openai/gpt-4.1", "approval_posture": "allow"}) + var result struct{ Agent struct{ ID string } } + if err := json.Unmarshal(created, &result); err != nil || result.Agent.ID == "" { + t.Fatalf("create: %s, %v", created, err) + } + id := result.Agent.ID + run := func(p *identityQAProcess) { + p.call("agents.send", map[string]any{"id": id, "text": "Run the identity QA shell command"}) + p.wait(func(f identityQAFrame) bool { return f.Event == "turn.done" }) + } + run(p) + p.stop() + + var truth string + if err := filepath.WalkDir(appHome, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.Name() == "session.json" && filepath.Base(filepath.Dir(filepath.Dir(path))) == id { + truth = path + } + return nil + }); err != nil || truth == "" { + t.Fatalf("no sealed session truth: %s %v", truth, err) + } + readDoc := func() agentdid.Document { + raw, err := os.ReadFile(truth) + if err != nil { + t.Fatal(err) + } + var state struct { + Identity agentdid.Document `json:"agent_identity"` + } + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + return state.Identity + } + doc := readDoc() + if doc.V != agentdid.Version || doc.Signing == nil || doc.Encryption == nil || len(doc.Custody) == 0 { + t.Fatalf("incomplete identity after a real turn: %+v", doc) + } + // The real bash tool wrote only the public identity into its environment. + checkShell := func() { + did, err := os.ReadFile(filepath.Join(workspace, "did.txt")) + if err != nil || string(did) != doc.DID { + t.Fatalf("shell DID: %q %v", did, err) + } + env, err := os.ReadFile(filepath.Join(workspace, "identity-env.txt")) + if err != nil || string(env) != agentdid.AgentDIDEnv+"="+doc.DID+"\n" { + t.Fatalf("shell identity environment: %q %v", env, err) + } + } + checkShell() + owner, err := identity.Load(appHome) + if err != nil { + t.Fatal(err) + } + keys, err := doc.Unlock(owner.CellKey()) + if err != nil { + t.Fatal(err) + } + sig, err := keys.Sign(doc.DID, "receipt/v1", []byte("real process")) + if err != nil || !agentdid.Verify(doc.DID, "receipt/v1", []byte("real process"), sig) { + t.Fatalf("persisted signing custody: %v", err) + } + p = start() + listed := p.call("agents.list", map[string]any{"state": "all"}) + if !bytes.Contains(listed, []byte(doc.DID)) || bytes.Contains(listed, []byte("custody")) || bytes.Contains(listed, []byte("encryption")) { + t.Fatalf("agent list must expose only the public DID: %s", listed) + } + run(p) + p.stop() + checkShell() + again := readDoc() + if !bytes.Equal(again.Custody, doc.Custody) || again.DID != doc.DID { + t.Fatal("restarting replaced the agent's keys") + } + + // Model an actual #2027 folder, then use the app's open and send doors. + writeDocument := func(document string) []byte { + raw, _ := os.ReadFile(truth) + var state map[string]json.RawMessage + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + state["agent_identity"] = json.RawMessage(document) + raw, _ = json.Marshal(state) + if err := os.WriteFile(truth, raw, 0o600); err != nil { + t.Fatal(err) + } + return raw + } + legacy, _ := json.Marshal(map[string]string{"did": doc.DID, "session": id}) + before := writeDocument(string(legacy)) + p = start() + p.call("agents.open", map[string]any{"id": id}) + after, _ := os.ReadFile(truth) + if !bytes.Equal(before, after) { + t.Fatal("opening a legacy chat rewrote its identity") + } + run(p) + p.stop() + upgraded := readDoc() + if upgraded.DID != doc.DID || upgraded.V != agentdid.Version || !upgraded.SigningUnavailable || upgraded.Encryption == nil || len(upgraded.Custody) == 0 { + t.Fatalf("legacy chat did not lazily upgrade: %+v", upgraded) + } + future := fmt.Sprintf(`{"v":"v99", "did":%q, "session":%q, "statements": [ {"future": 1.00} ]}`, doc.DID, id) + // Preserve the future value's original whitespace inside its enclosing file. + raw := writeDocument(future) + var compact json.RawMessage + var state map[string]json.RawMessage + _ = json.Unmarshal(raw, &state) + compact = state["agent_identity"] + raw = bytes.Replace(raw, compact, []byte(future), 1) + if err := os.WriteFile(truth, raw, 0o600); err != nil { + t.Fatal(err) + } + p = start() + p.call("agents.rename", map[string]any{"id": id, "title": "Future identity QA"}) + p.stop() + raw, _ = os.ReadFile(truth) + if !bytes.Contains(raw, []byte(future)) { + t.Fatalf("rename rewrote a future document: %s", raw) + } + // Reading the chat through its public session API must preserve that document. + meta, err := session.LoadMeta(filepath.Dir(filepath.Dir(truth))) + if err != nil || meta.AgentDID() != doc.DID { + t.Fatalf("public DID disappeared after future write: %v", err) + } + t.Log("actual app binary: create, real bash environment, custody, restart, legacy open/send, future rename passed") +} + +type identityQAFrame struct { + ID int `json:"id"` + Event string `json:"event"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` +} + +type identityQAProcess struct { + t *testing.T + cmd *exec.Cmd + in io.WriteCloser + frames chan identityQAFrame + pending []identityQAFrame + stderr bytes.Buffer + next int + stopped bool +} + +func startIdentityQAProcess(t *testing.T, binary, root, home, workspace, base string) *identityQAProcess { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + t.Cleanup(cancel) + p := &identityQAProcess{t: t, frames: make(chan identityQAFrame, 4096)} + p.cmd = exec.CommandContext(ctx, binary, "app", "--home", home) + p.cmd.Dir = workspace + p.cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(root, "home"), "TMPDIR=" + root, "OPENROUTER_API_KEY=sk-or-v1-identity-qa-dummy", "CODEAF_BASE_URL=" + base, "TERM=dumb"} + p.cmd.Stderr = &p.stderr + var err error + p.in, err = p.cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := p.cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := p.cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if !p.stopped { + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() + t.Logf("app stderr: %s", &p.stderr) + } + }) + go func() { + defer close(p.frames) + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 4096), 4<<20) + for scanner.Scan() { + var frame identityQAFrame + if json.Unmarshal(scanner.Bytes(), &frame) == nil { + p.frames <- frame + } + } + }() + p.wait(func(f identityQAFrame) bool { return f.Event == "hello" }) + return p +} + +func (p *identityQAProcess) wait(matches func(identityQAFrame) bool) identityQAFrame { + p.t.Helper() + for i, frame := range p.pending { + if matches(frame) { + p.pending = append(p.pending[:i], p.pending[i+1:]...) + return frame + } + } + deadline := time.NewTimer(40 * time.Second) + defer deadline.Stop() + for { + select { + case frame, ok := <-p.frames: + if !ok { + p.t.Fatal("app stdout ended before the expected response") + } + if matches(frame) { + return frame + } + p.pending = append(p.pending, frame) + case <-deadline.C: + p.t.Fatal("app did not answer within 40 seconds") + } + } +} + +func (p *identityQAProcess) call(method string, params any) json.RawMessage { + p.t.Helper() + p.next++ + if err := json.NewEncoder(p.in).Encode(map[string]any{"id": p.next, "method": method, "params": params}); err != nil { + p.t.Fatal(err) + } + f := p.wait(func(f identityQAFrame) bool { return f.ID == p.next }) + if len(f.Error) != 0 && string(f.Error) != "null" { + p.t.Fatalf("%s: %s", method, f.Error) + } + return f.Result +} + +func (p *identityQAProcess) stop() { + p.t.Helper() + p.call("shutdown", map[string]any{}) + _ = p.in.Close() + err := p.cmd.Wait() + p.stopped = true + if err != nil { + p.t.Fatalf("app exit: %v\n%s", err, &p.stderr) + } +} diff --git a/docs/changes/unreleased/0-agent-identity-custody.md b/docs/changes/unreleased/0-agent-identity-custody.md new file mode 100644 index 0000000000..e4c044c740 --- /dev/null +++ b/docs/changes/unreleased/0-agent-identity-custody.md @@ -0,0 +1,18 @@ +--- +kind: added +title: Agent identities retain encrypted signing and encryption keys +pr: 0 +surface: [chat, engine] +invalidates: + - "An agent DID used to be a public name only; new conversations now retain encrypted signing and encryption keys." +--- + +Each new conversation keeps a v1 identity document in its sealed truth, including +Ed25519 and companion X25519 public keys and encrypted private-key custody. +Purpose-separated signing verifies with the public DID alone. Commands and +`agents.list` still expose only the public DID. A copy or branch mints fresh keys. + +Public-only identities migrate on the next metadata write without changing their +DID. Their missing signing key remains explicitly unavailable; they gain encrypted +X25519 custody. Opening an old folder writes nothing. Unknown document versions +remain byte-identical across metadata writes. Agent key rotation is not included. diff --git a/internal/agentdid/agentdid.go b/internal/agentdid/agentdid.go index 20da535690..fccb66de19 100644 --- a/internal/agentdid/agentdid.go +++ b/internal/agentdid/agentdid.go @@ -11,9 +11,7 @@ // base64url without padding, after the `z` (control-plane's DIDService). Using // the same spelling lets the two products read each other's identifiers. // -// THE KEY IS NOT KEPT. A DID here is an identifier, not a credential: nothing -// signs with it today, so only the public half is minted and the private half -// is dropped at once. Nothing secret is ever written to disk for it. +// New conversations retain both keys through encrypted Document custody. package agentdid import ( diff --git a/internal/agentdid/credentials.go b/internal/agentdid/credentials.go new file mode 100644 index 0000000000..e88a8cffe8 --- /dev/null +++ b/internal/agentdid/credentials.go @@ -0,0 +1,297 @@ +package agentdid + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ed25519" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "strings" +) + +// Credentials hold private keys only in memory. JSON and formatting expose no +// key material; persistence goes through Document.Protect exclusively. +type Credentials struct { + signing [ed25519.SeedSize]byte + encryption [32]byte + hasSigning bool +} + +// String prevents ordinary diagnostic formatting from revealing key material. +func (Credentials) String() string { return "agent credentials (private)" } + +// GoString prevents detailed diagnostic formatting from revealing key material. +func (c Credentials) GoString() string { return c.String() } + +// Generate mints both keys without writing anything to disk. +func Generate(session string) (Document, *Credentials, error) { + c := &Credentials{hasSigning: true} + if _, err := rand.Read(c.signing[:]); err != nil { + return Document{}, nil, err + } + d := Document{V: Version, Session: session, pending: c, DID: FromPublicKey(c.public())} + if err := c.addEncryption(&d); err != nil { + return Document{}, nil, err + } + d.Signing = &Key{Type: "Ed25519", Public: encode(c.public())} + return d, c, nil +} + +// Upgrade preserves a public-only DID whose discarded private key cannot be +// recovered. It adds encryption and states the lack of signing custody honestly. +func Upgrade(d Document) (Document, *Credentials, error) { + if d.V != "" || !Valid(d.DID) { + return Document{}, nil, ErrDocument + } + c := &Credentials{} + d.V, d.SigningUnavailable = Version, true + d.Signing = &Key{Type: "Ed25519", Public: encode(publicKey(d.DID))} + if err := c.addEncryption(&d); err != nil { + return Document{}, nil, err + } + return d, c, nil +} + +func (c *Credentials) addEncryption(d *Document) error { + key, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return err + } + copy(c.encryption[:], key.Bytes()) + d.Encryption = &Key{Type: "X25519", Public: encode(key.PublicKey().Bytes())} + return nil +} + +func (c *Credentials) public() ed25519.PublicKey { + return ed25519.NewKeyFromSeed(c.signing[:]).Public().(ed25519.PublicKey) +} + +func encode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func publicKey(did string) ed25519.PublicKey { + if !Valid(did) { + return nil + } + raw, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(did, prefix)) + return ed25519.PublicKey(raw[len(multicodec):]) +} + +// purposeBody frames each component so a purpose or payload cannot be confused +// with an adjacent field. Every application purpose carries its own version. +func purposeBody(label, did, purpose string, payload []byte) ([]byte, error) { + name, version, ok := strings.Cut(purpose, "/v") + if !ok || name == "" || version == "" || version[0] == '0' { + return nil, errors.New("agent identity: purpose must end in /v") + } + for _, ch := range version { + if ch < '0' || ch > '9' { + return nil, errors.New("agent identity: invalid purpose version") + } + } + out := []byte(label) + for _, value := range [][]byte{[]byte(did), []byte(purpose), payload} { + out = binary.BigEndian.AppendUint64(out, uint64(len(value))) + out = append(out, value...) + } + return out, nil +} + +// Sign signs a purpose-framed statement only as the DID owned by these keys. +func (c *Credentials) Sign(did, purpose string, payload []byte) ([]byte, error) { + if c == nil || !c.hasSigning { + return nil, ErrUnavailable + } + if did != FromPublicKey(c.public()) { + return nil, ErrDocument + } + body, err := purposeBody("codeaf/agent-signature/v1", did, purpose, payload) + if err != nil { + return nil, err + } + return ed25519.Sign(ed25519.NewKeyFromSeed(c.signing[:]), body), nil +} + +// Verify needs only the public DID, never a cell, identity document or home key. +func Verify(did, purpose string, payload, signature []byte) bool { + public := publicKey(did) + if public == nil { + return false + } + body, err := purposeBody("codeaf/agent-signature/v1", did, purpose, payload) + return err == nil && ed25519.Verify(public, body, signature) +} + +func aeadFor(key []byte, label string) (cipher.AEAD, error) { + derived, err := hkdf.Key(sha256.New, key, nil, label, 32) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(derived) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func encrypt(key, plain, aad []byte, label string) ([]byte, error) { + aead, err := aeadFor(key, label) + if err != nil { + return nil, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return aead.Seal(nonce, nonce, plain, aad), nil +} + +func decrypt(key, box, aad []byte, label string) ([]byte, error) { + aead, err := aeadFor(key, label) + if err != nil { + return nil, err + } + if len(box) < aead.NonceSize()+aead.Overhead() { + return nil, ErrDocument + } + return aead.Open(nil, box[:aead.NonceSize()], box[aead.NonceSize():], aad) +} + +func (d Document) binding() []byte { + body, _ := purposeBody("codeaf/agent-custody/v1", d.DID, "custody/v1", []byte(d.Session)) + return body +} + +// Protect encrypts both private keys under the profile's cell key. The nonce +// and ciphertext travel inside the cell; the wrapping key never does. +func (d *Document) Protect(c *Credentials, cellKey []byte) error { + if len(cellKey) != 32 || d.V != Version || c == nil { + return ErrDocument + } + if err := c.matches(*d); err != nil { + return err + } + plain := make([]byte, 65) + if c.hasSigning { + plain[0] = 1 + } + copy(plain[1:33], c.signing[:]) + copy(plain[33:], c.encryption[:]) + defer clear(plain) + box, err := encrypt(cellKey, plain, d.binding(), "codeaf/agent-custody/v1") + if err == nil { + d.Custody = box + d.pending = nil + } + return err +} + +// Unlock refuses transplanted, corrupt and unsupported custody without +// exposing private material in errors. +func (d Document) Unlock(cellKey []byte) (*Credentials, error) { + if d.V == Version && len(d.Custody) == 0 && d.pending != nil { + return d.pending, d.pending.matches(d) + } + if d.V != Version || len(cellKey) != 32 { + return nil, ErrDocument + } + plain, err := decrypt(cellKey, d.Custody, d.binding(), "codeaf/agent-custody/v1") + if err != nil { + return nil, fmt.Errorf("agent identity: custody could not be opened") + } + defer clear(plain) + if len(plain) != 65 || plain[0] > 1 { + return nil, ErrDocument + } + c := &Credentials{hasSigning: plain[0] == 1} + copy(c.signing[:], plain[1:33]) + copy(c.encryption[:], plain[33:]) + return c, c.matches(d) +} + +func (c *Credentials) matches(d Document) error { + if d.Signing == nil || d.Encryption == nil || d.Signing.Type != "Ed25519" || d.Encryption.Type != "X25519" { + return ErrDocument + } + if d.Signing.Public != encode(publicKey(d.DID)) || !Valid(d.DID) { + return ErrDocument + } + if c.hasSigning == d.SigningUnavailable { + return ErrDocument + } + if c.hasSigning && FromPublicKey(c.public()) != d.DID { + return ErrDocument + } + key, err := ecdh.X25519().NewPrivateKey(c.encryption[:]) + if err != nil || d.Encryption.Public != encode(key.PublicKey().Bytes()) { + return ErrDocument + } + return nil +} + +// Seal encrypts material to the document's X25519 key, using a fresh ephemeral +// sender key. The recipient and purpose are authenticated with the ciphertext. +func Seal(d Document, purpose string, payload []byte) ([]byte, error) { + if d.V != Version || d.Encryption == nil || d.Encryption.Type != "X25519" || !Valid(d.DID) { + return nil, ErrDocument + } + aad, err := purposeBody("codeaf/agent-envelope/v1", d.DID, purpose, nil) + if err != nil { + return nil, err + } + raw, err := base64.RawURLEncoding.DecodeString(d.Encryption.Public) + if err != nil { + return nil, ErrDocument + } + public, err := ecdh.X25519().NewPublicKey(raw) + if err != nil { + return nil, err + } + ephemeral, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + shared, err := ephemeral.ECDH(public) + if err != nil { + return nil, err + } + defer clear(shared) + box, err := encrypt(shared, payload, aad, "codeaf/agent-envelope/v1") + if err != nil { + return nil, err + } + return append(ephemeral.PublicKey().Bytes(), box...), nil +} + +// Open decrypts material only with this document's private companion key. +func (c *Credentials) Open(d Document, purpose string, box []byte) ([]byte, error) { + if c == nil || d.V != Version || len(box) < 32 { + return nil, ErrDocument + } + if err := c.matches(d); err != nil { + return nil, err + } + aad, err := purposeBody("codeaf/agent-envelope/v1", d.DID, purpose, nil) + if err != nil { + return nil, err + } + public, err := ecdh.X25519().NewPublicKey(box[:32]) + if err != nil { + return nil, err + } + key, err := ecdh.X25519().NewPrivateKey(c.encryption[:]) + if err != nil { + return nil, err + } + shared, err := key.ECDH(public) + if err != nil { + return nil, err + } + defer clear(shared) + return decrypt(shared, box[32:], aad, "codeaf/agent-envelope/v1") +} diff --git a/internal/agentdid/credentials_test.go b/internal/agentdid/credentials_test.go new file mode 100644 index 0000000000..a7ee1f9f7d --- /dev/null +++ b/internal/agentdid/credentials_test.go @@ -0,0 +1,143 @@ +package agentdid + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "testing" +) + +func TestCredentialsProveTheDIDAndSeparatePurposes(t *testing.T) { + d, keys, err := Generate("chat") + if err != nil { + t.Fatal(err) + } + payload := []byte("a statement") + sig, err := keys.Sign(d.DID, "receipt/v1", payload) + if err != nil { + t.Fatal(err) + } + if !Verify(d.DID, "receipt/v1", payload, sig) { + t.Fatal("public DID did not verify") + } + for _, purpose := range []string{"receipt/v2", "other/v1", ""} { + if Verify(d.DID, purpose, payload, sig) { + t.Fatal("cross-purpose signature accepted") + } + } + if Verify(d.DID, "receipt/v1", []byte("changed"), sig) { + t.Fatal("changed statement accepted") + } + other, _, _ := Generate("other") + if _, err := keys.Sign(other.DID, "receipt/v1", payload); err == nil { + t.Fatal("signed another DID") + } + if _, err := keys.Sign(d.DID, "unversioned", payload); err == nil { + t.Fatal("unversioned purpose accepted") + } +} + +func TestCustodyIsEncryptedAndBoundToTheDocument(t *testing.T) { + d, keys, _ := Generate("chat") + wrapping := bytes.Repeat([]byte{3}, 32) + if err := d.Protect(keys, wrapping); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(d) + for _, secret := range [][]byte{keys.signing[:], keys.encryption[:]} { + for _, encoded := range [][]byte{secret, []byte(base64.StdEncoding.EncodeToString(secret)), []byte(encode(secret)), []byte(hex.EncodeToString(secret))} { + if bytes.Contains(raw, encoded) || bytes.Contains([]byte(fmt.Sprintf("%v %+v %#v", keys, keys, keys)), encoded) { + t.Fatal("plaintext key escaped JSON or diagnostics") + } + } + } + restored, err := d.Unlock(wrapping) + if err != nil { + t.Fatal(err) + } + if _, err := restored.Sign(d.DID, "test/v1", nil); err != nil { + t.Fatal(err) + } + if _, err := d.Unlock(bytes.Repeat([]byte{4}, 32)); err == nil { + t.Fatal("wrong home opened custody") + } + d.Session = "copy" + if _, err := d.Unlock(wrapping); err == nil { + t.Fatal("copy opened original custody") + } +} + +func TestCustodyRejectsCorruptionAndPublicKeySubstitution(t *testing.T) { + d, keys, err := Generate("chat") + if err != nil { + t.Fatal(err) + } + wrapping := bytes.Repeat([]byte{7}, 32) + d, err = d.Persist(wrapping) + if err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*Document){ + func(d *Document) { d.Custody[0] ^= 1 }, + func(d *Document) { d.Custody = d.Custody[:5] }, + func(d *Document) { d.DID, _ = New() }, + func(d *Document) { d.Signing.Public = "substituted" }, + func(d *Document) { d.Encryption.Public = "substituted" }, + func(d *Document) { d.SigningUnavailable = true }, + } { + raw, _ := json.Marshal(d) + var changed Document + if err := json.Unmarshal(raw, &changed); err != nil { + t.Fatal(err) + } + mutate(&changed) + if _, err := changed.Unlock(wrapping); err == nil { + t.Fatal("tampered custody opened") + } + } + for _, purpose := range []string{"", "receipt", "/v1", "receipt/v0", "receipt/v01", "receipt/v1/other"} { + if _, err := keys.Sign(d.DID, purpose, nil); err == nil { + t.Fatalf("invalid purpose %q accepted", purpose) + } + } +} + +func TestOnlyTheRecipientCanOpenSealedMaterial(t *testing.T) { + d, keys, _ := Generate("chat") + other, wrong, _ := Generate("other") + box, err := Seal(d, "message/v1", []byte("for one agent")) + if err != nil { + t.Fatal(err) + } + plain, err := keys.Open(d, "message/v1", box) + if err != nil || string(plain) != "for one agent" { + t.Fatalf("open: %q %v", plain, err) + } + if _, err := wrong.Open(other, "message/v1", box); err == nil { + t.Fatal("other agent opened message") + } + if _, err := keys.Open(d, "other/v1", box); err == nil { + t.Fatal("cross-purpose message opened") + } + box[len(box)-1] ^= 1 + if _, err := keys.Open(d, "message/v1", box); err == nil { + t.Fatal("damaged message opened") + } +} + +func TestUnknownDocumentRoundTrips(t *testing.T) { + raw := []byte(`{"v":"v99", "did":"future", "session":"chat", "keys":{"future":[1,2]}}`) + var d Document + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + got, _ := d.MarshalJSON() + if !bytes.Equal(got, raw) { + t.Fatalf("future document changed: %s", got) + } + if _, err := d.Unlock(make([]byte, 32)); err == nil { + t.Fatal("future document unlocked") + } +} diff --git a/internal/agentdid/document.go b/internal/agentdid/document.go new file mode 100644 index 0000000000..ecc2e5674e --- /dev/null +++ b/internal/agentdid/document.go @@ -0,0 +1,142 @@ +package agentdid + +import ( + "bytes" + "encoding/json" + "errors" +) + +// Version is the identity document format understood by this build. +const Version = "v1" + +// Key describes a public key without exposing its private half. +type Key struct { + Type string `json:"type"` + Public string `json:"public"` +} + +// Document keeps public keys and encrypted custody together as sealed truth. +// Unknown documents retain their original bytes, including future statements. +type Document struct { + V string `json:"v,omitempty"` + DID string `json:"did"` + Session string `json:"session"` + Signing *Key `json:"signing,omitempty"` + Encryption *Key `json:"encryption,omitempty"` + Custody []byte `json:"custody,omitempty"` + SigningUnavailable bool `json:"signing_unavailable,omitempty"` + pending *Credentials + raw json.RawMessage +} + +type documentWire Document + +// UnmarshalJSON reads routing fields while retaining the complete document. +func (d *Document) UnmarshalJSON(raw []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + *d = Document{raw: append([]byte(nil), raw...)} + _ = json.Unmarshal(fields["did"], &d.DID) + _ = json.Unmarshal(fields["session"], &d.Session) + if v, ok := fields["v"]; ok { + if json.Unmarshal(v, &d.V) != nil || d.V == "" { + d.V = "unknown" + } + } + if d.V != "" && d.V != Version { + return nil + } + var wire documentWire + if err := json.Unmarshal(raw, &wire); err != nil { + return err + } + wire.raw = d.raw + *d = Document(wire) + return nil +} + +// MarshalJSON never translates a document supplied by a newer writer. +func (d Document) MarshalJSON() ([]byte, error) { + if d.Unknown() && len(d.raw) != 0 { + return append([]byte(nil), d.raw...), nil + } + if d.V == Version && len(d.raw) != 0 { + // Keep additive fields even when this version does not interpret them. + var fields map[string]json.RawMessage + if err := json.Unmarshal(d.raw, &fields); err != nil { + return nil, err + } + encoded, err := json.Marshal(documentWire(d)) + if err != nil { + return nil, err + } + var known map[string]json.RawMessage + if err := json.Unmarshal(encoded, &known); err != nil { + return nil, err + } + for k, v := range known { + fields[k] = v + } + return json.Marshal(fields) + } + return json.Marshal(documentWire(d)) +} + +// Unknown reports a version that this build must preserve without interpreting. +func (d Document) Unknown() bool { return d.V != "" && d.V != Version } + +// PreserveJSON restores the original future document after an enclosing JSON +// encoder has indented it. This keeps its value byte-identical across meta writes. +func (d Document) PreserveJSON(encoded []byte) []byte { + if !d.Unknown() || len(d.raw) == 0 { + return encoded + } + marker := []byte(`"agent_identity":`) + start := bytes.Index(encoded, marker) + if start < 0 { + return encoded + } + start += len(marker) + for start < len(encoded) && (encoded[start] == ' ' || encoded[start] == '\n' || encoded[start] == '\t') { + start++ + } + var value json.RawMessage + if json.NewDecoder(bytes.NewReader(encoded[start:])).Decode(&value) != nil { + return encoded + } + result := append([]byte(nil), encoded[:start]...) + result = append(result, d.raw...) + return append(result, encoded[start+len(value):]...) +} + +var ( + // ErrUnavailable means the identity cannot prove ownership of its signing key. + ErrUnavailable = errors.New("agent identity: signing key unavailable") + // ErrDocument means a future or inconsistent document cannot be used for custody. + ErrDocument = errors.New("agent identity: unsupported or inconsistent document") +) + +// NeedsProtection identifies documents that need their first custody write. +func (d Document) NeedsProtection() bool { return d.V == "" || (d.V == Version && len(d.Custody) == 0) } + +// Persist returns an encrypted document without mutating a running agent's copy. +func (d Document) Persist(cellKey []byte) (Document, error) { + if !d.NeedsProtection() { + return d, nil + } + keys := d.pending + if d.V == "" { + var err error + d, keys, err = Upgrade(d) + if err != nil { + return Document{}, err + } + } + if err := d.Protect(keys, cellKey); err != nil { + return Document{}, err + } + d.pending = nil + return d, nil +} diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 10c273e71d..93afc44340 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3505,4 +3505,17 @@ record which agent acted. A task's commands see the conversation's DID followed by `#w` and the task's number, for example `did:key:z7QE…#w3`. The desktop app reads the same DID in the agent list as `did`. -The DID is a name, not a credential: no key is kept and nothing is signed with it. +New conversations retain an Ed25519 signing key and a separate X25519 encryption +key in encrypted custody inside their cell. The versioned identity document +travels with the chat; paired computers with the same cell key can open it. +Commands and the agent list receive only the public DID, never private keys. + +The engine can sign statements for a versioned purpose and open material sealed +to the companion encryption key. A signature can be verified with the DID alone; +it does not verify for a different purpose. These are engine APIs, not shell +commands or a new tool on the agent's belt. + +Older public-only identities upgrade on the next metadata write, never just by +opening the conversation. Their DID stays the same. Their discarded signing key +cannot be recovered, so signing remains unavailable; the upgrade adds encryption. +An identity document from a newer build is preserved without rewriting it. diff --git a/internal/session/agent_identity.go b/internal/session/agent_identity.go index bab9b98daa..5cc124053c 100644 --- a/internal/session/agent_identity.go +++ b/internal/session/agent_identity.go @@ -21,14 +21,13 @@ import ( "github.com/Agent-Field/codeaf/internal/agentdid" "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" ) -// AgentIdentity is one conversation's agent DID and the conversation id it -// was minted for. -type AgentIdentity struct { - DID string `json:"did"` - Session string `json:"session"` -} +// AgentIdentity is a conversation's versioned public document and encrypted +// key custody, bound to the conversation id it was minted for. +type AgentIdentity = agentdid.Document // AgentDID is the conversation's agent DID, or "" when it has none yet or the // one it holds was minted for another id. @@ -43,7 +42,7 @@ func (m Meta) AgentDID() string { // this id, else the one the save brings when it is bound to the save's id. // Nothing replaces an identity once written. func keptAgentIdentity(current, next Meta) *AgentIdentity { - if current.AgentDID() != "" && current.ID == next.ID { + if current.AgentIdentity != nil && current.ID == next.ID && (current.AgentIdentity.Unknown() || current.AgentDID() != "") { return current.AgentIdentity } if next.AgentDID() != "" { @@ -61,7 +60,10 @@ func keptAgentIdentity(current, next Meta) *AgentIdentity { // be minted or written leaves the variable unset, and the conversation opens. func configureAgentIdentity(config *Config) { if !config.InTask && config.Place.Dir != "" && config.agentDID == "" { - config.agentDID = conversationAgentDID(config.Place) + config.agentDocument = conversationAgentIdentity(config.Place) + if config.agentDocument != nil { + config.agentDID = (Meta{ID: config.Place.ID(), AgentIdentity: config.agentDocument}).AgentDID() + } config.actorDID = config.agentDID } if config.InTask && config.taskID != 0 && config.agentDID != "" { @@ -73,7 +75,7 @@ func configureAgentIdentity(config *Config) { config.Seat = executor.WithEnvironment(config.seat(), []string{agentdid.AgentDIDEnv + "=" + config.actorDID}) } -// conversationAgentDID answers the DID kept in place, or mints a fresh one. +// conversationAgentIdentity reads the kept document, or mints a fresh one. // // OPENING WRITES NOTHING. A conversation opens without touching its files (an // older folder must resume exactly as it was), so a minted DID is carried on @@ -81,14 +83,17 @@ func configureAgentIdentity(config *Config) { // ([Agent.fillMetaLocked]). That write comes at the latest when the first turn // ends, and a turn is the only thing that runs commands, so every DID a // command has seen is on disk once its turn is over. -func conversationAgentDID(place Place) string { +func conversationAgentIdentity(place Place) *AgentIdentity { if meta, err := LoadMeta(place.Dir); err == nil { - if did := meta.AgentDID(); did != "" { - return did + if meta.ID == place.ID() && meta.AgentIdentity != nil && (meta.AgentIdentity.Unknown() || meta.AgentDID() != "") { + return meta.AgentIdentity } } - did, _ := agentdid.New() - return did + doc, _, err := agentdid.Generate(place.ID()) + if err != nil { + return nil + } + return &doc } // agentIdentity is the identity a conversation writes into its own folder, and @@ -97,7 +102,7 @@ func (c Config) agentIdentity() *AgentIdentity { if c.InTask || c.agentDID == "" || c.Place.Dir == "" { return nil } - return &AgentIdentity{DID: c.agentDID, Session: c.Place.ID()} + return c.agentDocument } // AgentDID is who this agent's commands act as: the conversation's DID, or a @@ -107,3 +112,59 @@ func (a *Agent) AgentDID() string { defer a.mu.Unlock() return a.config.actorDID } + +// protectAgentIdentity runs only on a meta write. Reading an older folder +// neither creates a home identity nor upgrades its public-only record. +func protectAgentIdentity(doc *AgentIdentity) (*AgentIdentity, error) { + if doc == nil || !doc.NeedsProtection() { + return doc, nil + } + root, err := identity.EnsureSolo(home.Dir()) + if err != nil { + return nil, err + } + next, err := doc.Persist(root.CellKey()) + return &next, err +} + +// SignStatement proves this conversation's DID for one versioned purpose. +// Private material stays inside custody and is never handed to command tools. +func (a *Agent) SignStatement(purpose string, payload []byte) ([]byte, error) { + doc, keys, err := a.identityCredentials() + if err != nil { + return nil, err + } + return keys.Sign(doc.DID, purpose, payload) +} + +// OpenSealed opens material addressed to this conversation's companion key. +func (a *Agent) OpenSealed(purpose string, box []byte) ([]byte, error) { + doc, keys, err := a.identityCredentials() + if err != nil { + return nil, err + } + return keys.Open(*doc, purpose, box) +} + +func (a *Agent) identityCredentials() (*AgentIdentity, *agentdid.Credentials, error) { + a.mu.Lock() + defer a.mu.Unlock() + doc := a.config.agentDocument + if doc == nil || doc.Session != a.config.Place.ID() { + return nil, nil, agentdid.ErrUnavailable + } + // Load the durable upgrade if an earlier meta write supplied legacy custody. + if meta, err := LoadMeta(a.config.Place.Dir); err == nil && meta.AgentDID() == doc.DID && meta.AgentIdentity.V == agentdid.Version { + doc = meta.AgentIdentity + } + root, err := identity.Load(home.Dir()) + if err != nil { + keys, pendingErr := doc.Unlock(nil) + if pendingErr != nil { + return nil, nil, err + } + return doc, keys, nil + } + keys, err := doc.Unlock(root.CellKey()) + return doc, keys, err +} diff --git a/internal/session/agent_identity_custody_test.go b/internal/session/agent_identity_custody_test.go new file mode 100644 index 0000000000..ab44f885d5 --- /dev/null +++ b/internal/session/agent_identity_custody_test.go @@ -0,0 +1,194 @@ +package session + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/agentdid" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" +) + +func identityTruthFixture(t *testing.T, id, document string) (Place, string, []byte) { + t.Helper() + place := identityPlace(t, id) + state := filepath.Join(place.Dir, cellStateDir) + if err := os.MkdirAll(state, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(state, placeMetaTruth) + raw := []byte(`{"V":1,"agent_identity":` + document + `}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return place, path, raw +} + +func TestAgentIdentityCustodySurvivesRestartAndMovesWithCell(t *testing.T) { + profile := t.TempDir() + t.Setenv(home.EnvVar, profile) + place := identityPlace(t, "custody-chat") + if err := os.MkdirAll(filepath.Join(place.Dir, cellStateDir), 0o700); err != nil { + t.Fatal(err) + } + agent := openIdentityAgent(t, place, nil) + did := agent.AgentDID() + agent.stampMeta() + meta, err := LoadMeta(place.Dir) + if err != nil || meta.AgentIdentity == nil { + t.Fatalf("load identity: %v", err) + } + doc := *meta.AgentIdentity + if doc.V != agentdid.Version || doc.Signing == nil || doc.Encryption == nil || len(doc.Custody) == 0 { + t.Fatalf("missing versioned keys or encrypted custody: %+v", doc) + } + message := []byte("a receipt from this conversation") + sig, err := agent.SignStatement("receipt/v1", message) + if err != nil || !agentdid.Verify(did, "receipt/v1", message, sig) || agentdid.Verify(did, "other/v1", message, sig) { + t.Fatalf("sign/verify: %v", err) + } + box, err := agentdid.Seal(doc, "mail/v1", message) + if err != nil { + t.Fatal(err) + } + _ = agent.Close() + + // A relocated chat on a paired computer has the same profile cell key. + root, err := identity.Load(profile) + if err != nil { + t.Fatal(err) + } + pairedHome := t.TempDir() + if err := identity.Save(pairedHome, root); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, pairedHome) + moved := Place{Dir: filepath.Join(t.TempDir(), place.ID())} + if err := os.Rename(place.Dir, moved.Dir); err != nil { + t.Fatal(err) + } + again := openIdentityAgent(t, moved, nil) + if again.AgentDID() != did { + t.Fatal("moving or restarting replaced the DID") + } + plain, err := again.OpenSealed("mail/v1", box) + if err != nil || !bytes.Equal(plain, message) { + t.Fatalf("moved cell could not open its message: %v", err) + } + sig, err = again.SignStatement("receipt/v1", message) + if err != nil || !agentdid.Verify(did, "receipt/v1", message, sig) { + t.Fatalf("moved cell lost its signing key: %v", err) + } + other := openIdentityAgent(t, identityPlace(t, "other-chat"), nil) + if _, err := other.OpenSealed("mail/v1", box); err == nil { + t.Fatal("another conversation opened the sealed message") + } + // A copy with a new chat id cannot inherit signing or decryption custody. + meta.ID = "branched-chat" + branch := identityPlace(t, meta.ID) + if err := SaveMeta(branch.Dir, meta); err != nil { + t.Fatal(err) + } + branched := openIdentityAgent(t, branch, nil) + if branched.AgentDID() == did { + t.Fatal("branch kept its parent's DID") + } + if _, err := branched.OpenSealed("mail/v1", box); err == nil { + t.Fatal("branch opened its parent's message") + } + branched.stampMeta() + // Copying the files before rewriting meta.json also mints a fresh DID. + copyPlace := Place{Dir: filepath.Join(t.TempDir(), "copied-chat")} + if err := os.CopyFS(copyPlace.Dir, os.DirFS(moved.Dir)); err != nil { + t.Fatal(err) + } + copied := openIdentityAgent(t, copyPlace, nil) + if copied.AgentDID() == did || !agentdid.Valid(copied.AgentDID()) { + t.Fatal("a folder copy did not mint its own identity") + } + // Losing access to the wrapping key must fail closed, never mint a replacement. + t.Setenv(home.EnvVar, t.TempDir()) + if _, err := identity.EnsureSolo(home.Dir()); err != nil { + t.Fatal(err) + } + if _, err := again.SignStatement("receipt/v1", message); err == nil { + t.Fatal("unrelated profile unlocked custody") + } + if again.AgentDID() != did { + t.Fatal("failed unlock replaced the DID") + } +} + +func TestAgentIdentityLegacyUpgradeOnlyOnWrite(t *testing.T) { + profile := t.TempDir() + t.Setenv(home.EnvVar, profile) + did, _ := agentdid.New() + document, _ := json.Marshal(AgentIdentity{DID: did, Session: "legacy-chat"}) + place, path, before := identityTruthFixture(t, "legacy-chat", string(document)) + agent := openIdentityAgent(t, place, nil) + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("opening rewrote a public-only identity") + } + if _, err := identity.Load(profile); !errors.Is(err, identity.ErrNone) { + t.Fatalf("opening created profile custody: %v", err) + } + agent.stampMeta() + meta, err := LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + doc := meta.AgentIdentity + if meta.AgentDID() != did || doc.V != agentdid.Version || !doc.SigningUnavailable || doc.Encryption == nil || len(doc.Custody) == 0 { + t.Fatalf("invalid lazy upgrade: %+v", doc) + } + if _, err := agent.SignStatement("receipt/v1", nil); !errors.Is(err, agentdid.ErrUnavailable) { + t.Fatalf("discarded legacy key must be unavailable: %v", err) + } + box, err := agentdid.Seal(*doc, "mail/v1", []byte("legacy mail")) + if err != nil { + t.Fatal(err) + } + if plain, err := agent.OpenSealed("mail/v1", box); err != nil || string(plain) != "legacy mail" { + t.Fatalf("upgraded legacy decryption: %v", err) + } + agent.stampMeta() + next, _ := LoadMeta(place.Dir) + if next.AgentIdentity.Encryption.Public != doc.Encryption.Public || !bytes.Equal(next.AgentIdentity.Custody, doc.Custody) { + t.Fatal("a later save replaced upgraded keys") + } +} + +func TestAgentIdentityFutureDocumentPreservedVerbatimOnMetaWrites(t *testing.T) { + for _, fields := range []string{`"did":"future", "session":"future-chat",`, `"future_binding":["opaque"],`} { + t.Run(fields, func(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + document := `{"v":"v99", ` + fields + ` "statements": [ {"what": "future", "n": 1.00} ]}` + place, path, before := identityTruthFixture(t, "future-chat", document) + agent := openIdentityAgent(t, place, nil) + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("opening changed a future document") + } + agent.stampMeta() + for range 2 { + meta, err := LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + meta.Title = "an unrelated change" + if err := SaveMeta(place.Dir, meta); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(path) + if !bytes.Contains(after, []byte(document)) { + t.Fatalf("future document rewritten: %s", after) + } + } + }) + } +} diff --git a/internal/session/place.go b/internal/session/place.go index 08d70803ad..6e39225017 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -513,6 +513,10 @@ func saveMetaLocked(dir string, meta Meta) error { meta.ChatURI = current.ChatURI } meta.AgentIdentity = keptAgentIdentity(current, meta) + meta.AgentIdentity, err = protectAgentIdentity(meta.AgentIdentity) + if err != nil { + return fmt.Errorf("save agent identity: %w", err) + } // K3: schema paths use slashes even when their source machine is Windows. for _, field := range []*string{&meta.Workspace, &meta.Origin, &meta.FromWorkspace} { if windowsSource(*field) { @@ -540,6 +544,16 @@ func writeJSONAtomic(path string, v any) error { if err != nil { return err } + switch value := v.(type) { + case Meta: + if value.AgentIdentity != nil { + raw = value.AgentIdentity.PreserveJSON(raw) + } + case SessionTruth: + if value.AgentIdentity != nil { + raw = value.AgentIdentity.PreserveJSON(raw) + } + } tmp, err := os.CreateTemp(filepath.Dir(path), ".meta-*.json") if err != nil { return err diff --git a/internal/session/session.go b/internal/session/session.go index f2149faac0..089b5b170e 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2611,8 +2611,9 @@ type Config struct { // commands act as: the same DID in a conversation, `#w` in a // task worker (agent_identity.go). A child agent inherits both from the // agent that built it ([Agent.newChildAgent]); no surface sets them. - agentDID string - actorDID string + agentDID string + agentDocument *AgentIdentity + actorDID string // automationID is the id of the automation whose run this agent IS // (automation_run.go), and empty in every conversation and every ordinary From 39b698c3790e046b59a66a6d45a73fcacb7f6336 Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:31:34 -0400 Subject: [PATCH 2/9] docs: record agent identity custody in PR 2090 --- ...agent-identity-custody.md => 2090-agent-identity-custody.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{0-agent-identity-custody.md => 2090-agent-identity-custody.md} (98%) diff --git a/docs/changes/unreleased/0-agent-identity-custody.md b/docs/changes/unreleased/2090-agent-identity-custody.md similarity index 98% rename from docs/changes/unreleased/0-agent-identity-custody.md rename to docs/changes/unreleased/2090-agent-identity-custody.md index e4c044c740..dd21e428c3 100644 --- a/docs/changes/unreleased/0-agent-identity-custody.md +++ b/docs/changes/unreleased/2090-agent-identity-custody.md @@ -1,7 +1,7 @@ --- kind: added title: Agent identities retain encrypted signing and encryption keys -pr: 0 +pr: 2090 surface: [chat, engine] invalidates: - "An agent DID used to be a public name only; new conversations now retain encrypted signing and encryption keys." From e6502634841a4fdaaec4a17eea5547a4d3f427b6 Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:38:10 -0400 Subject: [PATCH 3/9] session: restore saved dials without migrating identity on open --- .github/workflows/agent-identity.yml | 7 ++++ cmd/codeaf/app_agent_identity_e2e_test.go | 2 +- internal/agentdid/credentials.go | 8 +++- internal/agentdid/credentials_test.go | 39 +++++++++++++++++++ internal/manual/chat/how-tasks-run.md | 2 +- .../session/agent_identity_custody_test.go | 35 +++++++++++++++++ internal/session/approvalposture.go | 8 ++++ internal/session/effort.go | 6 +++ 8 files changed, 103 insertions(+), 4 deletions(-) diff --git a/.github/workflows/agent-identity.yml b/.github/workflows/agent-identity.yml index 0ee91301f3..86f9851537 100644 --- a/.github/workflows/agent-identity.yml +++ b/.github/workflows/agent-identity.yml @@ -35,8 +35,15 @@ jobs: - name: Exercise the app as a desktop client run: go test ./cmd/codeaf -run '^TestAppAgentIdentityBinaryE2E$' -count=1 -v -timeout 5m - name: Race-check custody and lifecycle + if: ${{ !cancelled() }} run: go test -race ./internal/agentdid ./internal/session -run 'TestCredentials|TestCustody|TestOnlyTheRecipient|TestUnknownDocument|TestAgentIdentity|TestAgentDID|TestToolCommandsSeeTheAgentDID|TestTaskWorker.*DID' -count=1 -timeout 15m - name: Run the repository PR gates against app/v3 + if: ${{ !cancelled() }} env: BASE: ${{ github.event.pull_request.base.sha }} run: make pr-ready + - name: Run touched packages even if an existing light gate fails + if: ${{ !cancelled() }} + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: make test-touched diff --git a/cmd/codeaf/app_agent_identity_e2e_test.go b/cmd/codeaf/app_agent_identity_e2e_test.go index 846bacab2a..b72acb9aa5 100644 --- a/cmd/codeaf/app_agent_identity_e2e_test.go +++ b/cmd/codeaf/app_agent_identity_e2e_test.go @@ -181,7 +181,7 @@ func TestAppAgentIdentityBinaryE2E(t *testing.T) { p.call("agents.open", map[string]any{"id": id}) after, _ := os.ReadFile(truth) if !bytes.Equal(before, after) { - t.Fatal("opening a legacy chat rewrote its identity") + t.Fatalf("opening a legacy chat rewrote its identity: before %s; after %s", before, after) } run(p) p.stop() diff --git a/internal/agentdid/credentials.go b/internal/agentdid/credentials.go index e88a8cffe8..8d1fa8ebc0 100644 --- a/internal/agentdid/credentials.go +++ b/internal/agentdid/credentials.go @@ -85,8 +85,12 @@ func publicKey(did string) ed25519.PublicKey { // purposeBody frames each component so a purpose or payload cannot be confused // with an adjacent field. Every application purpose carries its own version. func purposeBody(label, did, purpose string, payload []byte) ([]byte, error) { - name, version, ok := strings.Cut(purpose, "/v") - if !ok || name == "" || version == "" || version[0] == '0' { + separator := strings.LastIndex(purpose, "/v") + if separator <= 0 { + return nil, errors.New("agent identity: purpose must end in /v") + } + version := purpose[separator+2:] + if version == "" || version[0] == '0' { return nil, errors.New("agent identity: purpose must end in /v") } for _, ch := range version { diff --git a/internal/agentdid/credentials_test.go b/internal/agentdid/credentials_test.go index a7ee1f9f7d..f0e13659cd 100644 --- a/internal/agentdid/credentials_test.go +++ b/internal/agentdid/credentials_test.go @@ -141,3 +141,42 @@ func TestUnknownDocumentRoundTrips(t *testing.T) { t.Fatal("future document unlocked") } } + +func TestCredentialsRejectMalformedEnvelopesAndSupportHierarchicalPurposes(t *testing.T) { + d, keys, err := Generate("chat") + if err != nil { + t.Fatal(err) + } + const purpose = "codeaf/verification/receipt/v1" + sig, err := keys.Sign(d.DID, purpose, nil) + if err != nil || !Verify(d.DID, purpose, nil, sig) { + t.Fatalf("hierarchical versioned purpose: %v", err) + } + for _, did := range []string{"", "did:key:z", "did:key:z!!!!", d.DID + "#w1"} { + if Verify(did, purpose, nil, sig) { + t.Fatalf("malformed agent DID %q verified", did) + } + } + box, err := Seal(d, purpose, []byte("private")) + if err != nil { + t.Fatal(err) + } + for _, broken := range [][]byte{nil, box[:5], box[:32], append(make([]byte, 32), box[32:]...)} { + if _, err := keys.Open(d, purpose, broken); err == nil { + t.Fatal("malformed or low-order envelope opened") + } + } + if _, err := (*Credentials)(nil).Sign(d.DID, purpose, nil); err == nil { + t.Fatal("nil custody signed a statement") + } + if _, err := (*Credentials)(nil).Open(d, purpose, box); err == nil { + t.Fatal("nil custody opened an envelope") + } + for _, public := range []string{"!", encode(make([]byte, 31)), encode(make([]byte, 32))} { + broken := d + broken.Encryption = &Key{Type: "X25519", Public: public} + if _, err := Seal(broken, purpose, nil); err == nil { + t.Fatal("invalid recipient key accepted") + } + } +} diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 93afc44340..78bfde002a 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3511,7 +3511,7 @@ travels with the chat; paired computers with the same cell key can open it. Commands and the agent list receive only the public DID, never private keys. The engine can sign statements for a versioned purpose and open material sealed -to the companion encryption key. A signature can be verified with the DID alone; +to the companion encryption key. A signature can be checked with the DID alone; it does not verify for a different purpose. These are engine APIs, not shell commands or a new tool on the agent's belt. diff --git a/internal/session/agent_identity_custody_test.go b/internal/session/agent_identity_custody_test.go index ab44f885d5..58f13cef2d 100644 --- a/internal/session/agent_identity_custody_test.go +++ b/internal/session/agent_identity_custody_test.go @@ -192,3 +192,38 @@ func TestAgentIdentityFutureDocumentPreservedVerbatimOnMetaWrites(t *testing.T) }) } } + +func TestAgentIdentityRestoringSavedDialsDoesNotMigrateOnOpen(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + did, _ := agentdid.New() + document, _ := json.Marshal(AgentIdentity{DID: did, Session: "saved-dials"}) + place, path, _ := identityTruthFixture(t, "saved-dials", string(document)) + raw := []byte(`{"V":1,"approval":"allow","effort":"high","agent_identity":` + string(document) + `}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Place = place + config.ApprovalGate = &fakeApprovalGate{standing: PostureAsk} + }) + if err := agent.SetApprovalPosture(PostureAllow); err != nil { + t.Fatal(err) + } + if !agent.SetConversationEffort("high") { + t.Fatal("saved effort was refused") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(raw, after) { + t.Fatal("restoring preferences migrated the identity on open") + } + if agent.ApprovalPosture() != PostureAllow || agent.ConversationEffort() != "high" { + t.Fatal("restored preferences were not applied in memory") + } + if !agent.SetConversationEffort("low") { + t.Fatal("new effort was refused") + } + meta, _ := LoadMeta(place.Dir) + if meta.AgentIdentity.V != agentdid.Version || meta.Effort != "low" { + t.Fatal("an actual preference change did not persist and migrate") + } +} diff --git a/internal/session/approvalposture.go b/internal/session/approvalposture.go index 510949de50..7cfa0f6794 100644 --- a/internal/session/approvalposture.go +++ b/internal/session/approvalposture.go @@ -181,6 +181,14 @@ func (a *Agent) SetApprovalPosture(posture string) error { a.approvalPosture = word a.guardianOverride = &guardian a.mu.Unlock() + // The launcher restores the saved dial through this door. Applying the + // gate in memory must not turn an unchanged preference into a meta write + // (and therefore an identity migration) just because a chat was opened. + if a.config.Place.Dir != "" { + if meta, err := LoadMeta(a.config.Place.Dir); err == nil && meta.Approval == word { + return nil + } + } // Written through the same door every other fact about this conversation is // written through, so the file keeps one shape and one writer. a.stampApproval() diff --git a/internal/session/effort.go b/internal/session/effort.go index d39c3f2abe..3d47fef5ac 100644 --- a/internal/session/effort.go +++ b/internal/session/effort.go @@ -86,6 +86,12 @@ func (a *Agent) SetConversationEffort(rung string) bool { a.mu.Lock() a.effort = parsed a.mu.Unlock() + // Reapplying a stored dial on resume changes memory, not the chat's files. + if a.config.Place.Dir != "" { + if meta, err := LoadMeta(a.config.Place.Dir); err == nil && meta.Effort == parsed.String() { + return true + } + } // Written through the same door every other fact about this conversation is // written through, so the file keeps one shape and one writer. a.stampEffort() From 19a537b25f2c5ed0fbf1544aa44f1e031a184e6b Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:44:51 -0400 Subject: [PATCH 4/9] agentdid: return no credentials when custody validation fails --- internal/agentdid/credentials.go | 12 ++++++++++-- internal/agentdid/credentials_test.go | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/agentdid/credentials.go b/internal/agentdid/credentials.go index 8d1fa8ebc0..d59c1adc44 100644 --- a/internal/agentdid/credentials.go +++ b/internal/agentdid/credentials.go @@ -199,7 +199,10 @@ func (d *Document) Protect(c *Credentials, cellKey []byte) error { // exposing private material in errors. func (d Document) Unlock(cellKey []byte) (*Credentials, error) { if d.V == Version && len(d.Custody) == 0 && d.pending != nil { - return d.pending, d.pending.matches(d) + if err := d.pending.matches(d); err != nil { + return nil, err + } + return d.pending, nil } if d.V != Version || len(cellKey) != 32 { return nil, ErrDocument @@ -215,7 +218,12 @@ func (d Document) Unlock(cellKey []byte) (*Credentials, error) { c := &Credentials{hasSigning: plain[0] == 1} copy(c.signing[:], plain[1:33]) copy(c.encryption[:], plain[33:]) - return c, c.matches(d) + if err := c.matches(d); err != nil { + clear(c.signing[:]) + clear(c.encryption[:]) + return nil, err + } + return c, nil } func (c *Credentials) matches(d Document) error { diff --git a/internal/agentdid/credentials_test.go b/internal/agentdid/credentials_test.go index f0e13659cd..e13e40d84a 100644 --- a/internal/agentdid/credentials_test.go +++ b/internal/agentdid/credentials_test.go @@ -93,8 +93,8 @@ func TestCustodyRejectsCorruptionAndPublicKeySubstitution(t *testing.T) { t.Fatal(err) } mutate(&changed) - if _, err := changed.Unlock(wrapping); err == nil { - t.Fatal("tampered custody opened") + if opened, err := changed.Unlock(wrapping); err == nil || opened != nil { + t.Fatal("tampered custody returned private credentials") } } for _, purpose := range []string{"", "receipt", "/v1", "receipt/v0", "receipt/v01", "receipt/v1/other"} { From 9ff91518a425d817db262529defc2f84dd3ca515 Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:15:26 -0400 Subject: [PATCH 5/9] session: keep profile sync posture unchanged when protecting agent keys --- .github/workflows/agent-identity.yml | 24 +++++++++++++++++++ internal/session/agent_identity.go | 4 +++- .../session/agent_identity_custody_test.go | 19 +++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agent-identity.yml b/.github/workflows/agent-identity.yml index 86f9851537..8716273793 100644 --- a/.github/workflows/agent-identity.yml +++ b/.github/workflows/agent-identity.yml @@ -37,6 +37,9 @@ jobs: - name: Race-check custody and lifecycle if: ${{ !cancelled() }} run: go test -race ./internal/agentdid ./internal/session -run 'TestCredentials|TestCustody|TestOnlyTheRecipient|TestUnknownDocument|TestAgentIdentity|TestAgentDID|TestToolCommandsSeeTheAgentDID|TestTaskWorker.*DID' -count=1 -timeout 15m + - name: Check that custody preserves existing sync behavior + if: ${{ !cancelled() }} + run: go test ./cmd/codeaf -run '^TestAppChatRowsR2SavedTurnTime$|^TestAppChatStartupR3QuietRelease$|^TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable$|^TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat$|^TestDriveSideDoorPublishesAndReleases$|^TestDriveSideStartsWhenAPairingMakesTheIdentity$|^TestAChatMovedBackGetsADriveSideOfItsOwn$|^TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn$' -count=1 -timeout 5m - name: Run the repository PR gates against app/v3 if: ${{ !cancelled() }} env: @@ -47,3 +50,24 @@ jobs: env: BASE: ${{ github.event.pull_request.base.sha }} run: make test-touched + + baseline: + name: Compare existing failures on the untouched base + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build the untouched base + run: make build + - name: Probe command failures on the base + run: go test ./cmd/codeaf -run '^(TestAppChatRowsR2SavedTurnTime|TestAppChatStartupR3QuietRelease|TestAppTakeC1bSavedModelTravelsBack|TestC1AppViewerAcceptsRealPendingConsent|TestC1C2AppViewerRefusesWorkBeforeSubmission|TestRecipeDoorWritesTheWorktreeWhenOnlyTheCheckoutIsHeld|TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable|TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat|TestHostedTakeBackReachesAcquireWithoutSharedUploadsOrReleaseDelay|TestDriveSideDoorPublishesAndReleases|TestDriveSideStartsWhenAPairingMakesTheIdentity|TestAChatMovedBackGetsADriveSideOfItsOwn|TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn)$' -count=1 -v -timeout 5m + - name: Probe session failures on the base + if: ${{ !cancelled() }} + run: go test ./internal/session -run '^TestTheFixedPrefixStaysUnderItsBudget$|^TestTheLeanPrefixStaysUnderItsBudget$|^TestATurnThatMustReportRequiresTheVerbOnItsFirstRequestOnly$|^TestALandStartsNoAutomaticMaintenance$|^TestBashBeltTruncationCapFollowsTheWindow$|^TestTheStageVerbsCostOnlyTheStageAndStayUnderTheirBudget$|^TestBashBeltTruncationWritesHeadTailAndPath$|^TestProgramHoldClassifiesEveryRegisteredChatTool$|^TestNoRoadInTheMemoryEngineHasMoreEndingsThanTheCeiling$|^TestNoRoadInTheTaskEngineHasMoreEndingsThanItsLedgerRow$' -count=1 -v -timeout 5m + diff --git a/internal/session/agent_identity.go b/internal/session/agent_identity.go index 5cc124053c..c4f261f27a 100644 --- a/internal/session/agent_identity.go +++ b/internal/session/agent_identity.go @@ -119,7 +119,9 @@ func protectAgentIdentity(doc *AgentIdentity) (*AgentIdentity, error) { if doc == nil || !doc.NeedsProtection() { return doc, nil } - root, err := identity.EnsureSolo(home.Dir()) + // Pairing policy belongs to the launcher. A custody write must neither + // mark a profile solo nor change an existing profile's sync posture. + root, err := identity.Ensure(home.Dir()) if err != nil { return nil, err } diff --git a/internal/session/agent_identity_custody_test.go b/internal/session/agent_identity_custody_test.go index 58f13cef2d..fcefe156c4 100644 --- a/internal/session/agent_identity_custody_test.go +++ b/internal/session/agent_identity_custody_test.go @@ -227,3 +227,22 @@ func TestAgentIdentityRestoringSavedDialsDoesNotMigrateOnOpen(t *testing.T) { t.Fatal("an actual preference change did not persist and migrate") } } + +func TestAgentIdentityCustodyDoesNotChooseTheProfilesSyncPosture(t *testing.T) { + for _, solo := range []bool{false, true} { + t.Run(map[bool]string{false: "unmarked", true: "solo"}[solo], func(t *testing.T) { + profile := t.TempDir() + t.Setenv(home.EnvVar, profile) + if solo { + if _, err := identity.EnsureSolo(profile); err != nil { + t.Fatal(err) + } + } + agent := openIdentityAgent(t, identityPlace(t, "sync-posture"), nil) + agent.stampMeta() + if identity.Solo(profile) != solo { + t.Fatal("custody changed whether this profile talks to sync") + } + }) + } +} From 345b3815c3588fa9e40e18f7272d84145ff295b1 Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:50 -0400 Subject: [PATCH 6/9] test: check first-launch sync privacy through the app binary --- .github/workflows/agent-identity.yml | 1 - cmd/codeaf/app_agent_identity_e2e_test.go | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agent-identity.yml b/.github/workflows/agent-identity.yml index 8716273793..f59b822422 100644 --- a/.github/workflows/agent-identity.yml +++ b/.github/workflows/agent-identity.yml @@ -70,4 +70,3 @@ jobs: - name: Probe session failures on the base if: ${{ !cancelled() }} run: go test ./internal/session -run '^TestTheFixedPrefixStaysUnderItsBudget$|^TestTheLeanPrefixStaysUnderItsBudget$|^TestATurnThatMustReportRequiresTheVerbOnItsFirstRequestOnly$|^TestALandStartsNoAutomaticMaintenance$|^TestBashBeltTruncationCapFollowsTheWindow$|^TestTheStageVerbsCostOnlyTheStageAndStayUnderTheirBudget$|^TestBashBeltTruncationWritesHeadTailAndPath$|^TestProgramHoldClassifiesEveryRegisteredChatTool$|^TestNoRoadInTheMemoryEngineHasMoreEndingsThanTheCeiling$|^TestNoRoadInTheTaskEngineHasMoreEndingsThanItsLedgerRow$' -count=1 -v -timeout 5m - diff --git a/cmd/codeaf/app_agent_identity_e2e_test.go b/cmd/codeaf/app_agent_identity_e2e_test.go index b72acb9aa5..58652bcbc5 100644 --- a/cmd/codeaf/app_agent_identity_e2e_test.go +++ b/cmd/codeaf/app_agent_identity_e2e_test.go @@ -136,6 +136,9 @@ func TestAppAgentIdentityBinaryE2E(t *testing.T) { } } checkShell() + if !identity.Solo(appHome) { + t.Fatal("a first app launch lost its solo sync posture") + } owner, err := identity.Load(appHome) if err != nil { t.Fatal(err) From 6d478cf8008b4ef54ddc523df7ee477d832eac9c Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:27:40 -0400 Subject: [PATCH 7/9] app: establish first-launch privacy before creating conversation custody --- .github/workflows/agent-identity.yml | 40 +++++++++++++++++++++++----- cmd/codeaf/app.go | 6 +++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/workflows/agent-identity.yml b/.github/workflows/agent-identity.yml index f59b822422..d0d2571bb9 100644 --- a/.github/workflows/agent-identity.yml +++ b/.github/workflows/agent-identity.yml @@ -19,9 +19,10 @@ concurrency: cancel-in-progress: true jobs: - acceptance: + binary-e2e: + name: Binary user-flow QA runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: @@ -34,14 +35,39 @@ jobs: run: make build - name: Exercise the app as a desktop client run: go test ./cmd/codeaf -run '^TestAppAgentIdentityBinaryE2E$' -count=1 -v -timeout 5m + - name: Check that custody preserves existing sync behavior + run: go test ./cmd/codeaf -run '^TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable$|^TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat$|^TestDriveSideDoorPublishesAndReleases$|^TestDriveSideStartsWhenAPairingMakesTheIdentity$|^TestAChatMovedBackGetsADriveSideOfItsOwn$|^TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn$' -count=1 -v -timeout 5m + + race: + name: Custody race checks + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true - name: Race-check custody and lifecycle - if: ${{ !cancelled() }} run: go test -race ./internal/agentdid ./internal/session -run 'TestCredentials|TestCustody|TestOnlyTheRecipient|TestUnknownDocument|TestAgentIdentity|TestAgentDID|TestToolCommandsSeeTheAgentDID|TestTaskWorker.*DID' -count=1 -timeout 15m - - name: Check that custody preserves existing sync behavior - if: ${{ !cancelled() }} - run: go test ./cmd/codeaf -run '^TestAppChatRowsR2SavedTurnTime$|^TestAppChatStartupR3QuietRelease$|^TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable$|^TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat$|^TestDriveSideDoorPublishesAndReleases$|^TestDriveSideStartsWhenAPairingMakesTheIdentity$|^TestAChatMovedBackGetsADriveSideOfItsOwn$|^TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn$' -count=1 -timeout 5m + + repository: + name: Repository gates + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build with the pinned engine + run: make build - name: Run the repository PR gates against app/v3 - if: ${{ !cancelled() }} env: BASE: ${{ github.event.pull_request.base.sha }} run: make pr-ready diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 88bbdb7305..7c9bd77bf1 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -58,6 +58,7 @@ import ( "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/leave" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/orchestrate" @@ -123,6 +124,11 @@ func runApp(args []string) error { return err } } + // A new app profile is quiet until paired. Establish that policy before + // any conversation creates custody; session metadata never chooses it. + if _, err := identity.EnsureSolo(home.Dir()); err != nil { + return fmt.Errorf("prepare local identity: %w", err) + } // STDOUT IS THE PROTOCOL'S AND NOBODY ELSE'S. The wire keeps the real // descriptor, and anything else in this process that prints to stdout // lands on stderr, where the app collects logs. From 697d5a17a45c6a582487360c39a9e09d39fd04e9 Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:56:06 -0400 Subject: [PATCH 8/9] Preserve first-use privacy and encode custody without path-like ciphertext --- .github/workflows/agent-identity.yml | 12 ++++++- cmd/codeaf/app.go | 6 ---- cmd/codeaf/heldchat_drive_test.go | 4 +-- cmd/codeaf/syncdrive_test.go | 22 ++++++++---- internal/agentdid/credentials_test.go | 15 ++++++++ internal/agentdid/document.go | 35 +++++++++++++++---- internal/session/agent_identity.go | 6 ++-- .../session/agent_identity_custody_test.go | 20 ++++++++--- 8 files changed, 91 insertions(+), 29 deletions(-) diff --git a/.github/workflows/agent-identity.yml b/.github/workflows/agent-identity.yml index d0d2571bb9..14d457cb66 100644 --- a/.github/workflows/agent-identity.yml +++ b/.github/workflows/agent-identity.yml @@ -8,6 +8,10 @@ on: - internal/session/agent_identity*.go - internal/session/place.go - internal/session/session.go + - internal/session/approvalposture.go + - internal/session/effort.go + - cmd/codeaf/syncdrive_test.go + - cmd/codeaf/heldchat_drive_test.go - cmd/codeaf/app_agent_identity_e2e_test.go - .github/workflows/agent-identity.yml @@ -36,7 +40,10 @@ jobs: - name: Exercise the app as a desktop client run: go test ./cmd/codeaf -run '^TestAppAgentIdentityBinaryE2E$' -count=1 -v -timeout 5m - name: Check that custody preserves existing sync behavior - run: go test ./cmd/codeaf -run '^TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable$|^TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat$|^TestDriveSideDoorPublishesAndReleases$|^TestDriveSideStartsWhenAPairingMakesTheIdentity$|^TestAChatMovedBackGetsADriveSideOfItsOwn$|^TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn$' -count=1 -v -timeout 5m + run: go test ./cmd/codeaf -run '^TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable$|^TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat$|^TestDriveSideDoorPublishesAndReleases$|^TestDriveSideStartsWhenAPairingMakesTheIdentity$|^TestAChatMovedBackGetsADriveSideOfItsOwn$|^TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn$|^TestDriveSideStartsWhenTheSoloMarkerEnds$|^TestAppOrdinaryClientIDSurvivesABackendRestart$|^TestC6AppSendWithoutAViewerStaysLocal$|^TestC27NewChatByDefaultStaysQuiet$' -count=1 -v -timeout 5m + + - name: Repeat sealed history portability + run: go test ./internal/session -run '^TestSealedTreeCarriesTaskHistory$' -count=20 -v -timeout 5m race: name: Custody race checks @@ -96,3 +103,6 @@ jobs: - name: Probe session failures on the base if: ${{ !cancelled() }} run: go test ./internal/session -run '^TestTheFixedPrefixStaysUnderItsBudget$|^TestTheLeanPrefixStaysUnderItsBudget$|^TestATurnThatMustReportRequiresTheVerbOnItsFirstRequestOnly$|^TestALandStartsNoAutomaticMaintenance$|^TestBashBeltTruncationCapFollowsTheWindow$|^TestTheStageVerbsCostOnlyTheStageAndStayUnderTheirBudget$|^TestBashBeltTruncationWritesHeadTailAndPath$|^TestProgramHoldClassifiesEveryRegisteredChatTool$|^TestNoRoadInTheMemoryEngineHasMoreEndingsThanTheCeiling$|^TestNoRoadInTheTaskEngineHasMoreEndingsThanItsLedgerRow$' -count=1 -v -timeout 5m + - name: Compare the intermittent takeover assertion on the base + if: ${{ !cancelled() }} + run: go test ./internal/session -run '^TestAWithdrawnOrStaleTakeoverIsIgnored$' -count=100 -v -timeout 5m diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 7c9bd77bf1..88bbdb7305 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -58,7 +58,6 @@ import ( "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" - "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/leave" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/orchestrate" @@ -124,11 +123,6 @@ func runApp(args []string) error { return err } } - // A new app profile is quiet until paired. Establish that policy before - // any conversation creates custody; session metadata never chooses it. - if _, err := identity.EnsureSolo(home.Dir()); err != nil { - return fmt.Errorf("prepare local identity: %w", err) - } // STDOUT IS THE PROTOCOL'S AND NOBODY ELSE'S. The wire keeps the real // descriptor, and anything else in this process that prints to stdout // lands on stderr, where the app collects logs. diff --git a/cmd/codeaf/heldchat_drive_test.go b/cmd/codeaf/heldchat_drive_test.go index ef508eee0a..cc37718acd 100644 --- a/cmd/codeaf/heldchat_drive_test.go +++ b/cmd/codeaf/heldchat_drive_test.go @@ -209,7 +209,7 @@ func TestAChatAnotherProcessHoldsIsNeitherRenewedNorReleasedFromHere(t *testing. } func TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) s, writes := heldChatRelay(t, c.ID) firstBook := syncDrives defer firstBook.closeAll() @@ -276,7 +276,7 @@ func TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable(t *testing.T) { } func TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) s, writes := heldChatRelay(t, c.ID) firstBook := syncDrives defer firstBook.closeAll() diff --git a/cmd/codeaf/syncdrive_test.go b/cmd/codeaf/syncdrive_test.go index 341f1b7f5d..d7ccf42322 100644 --- a/cmd/codeaf/syncdrive_test.go +++ b/cmd/codeaf/syncdrive_test.go @@ -53,8 +53,18 @@ func doorChat(t *testing.T) (session.Config, cell.Cell) { // spokenDoorChat records a person's command through the ordinary session input. // Publication fixtures need conversation work rather than an unused startup chat. func spokenDoorChat(t *testing.T) (session.Config, cell.Cell) { + return spokenDoorChatWithPairing(t, false) +} + +// Shared fixtures establish their paired profile before the first custody write. +func spokenDoorChatWithPairing(t *testing.T, paired bool) (session.Config, cell.Cell) { t.Helper() cfg, c := doorChat(t) + if paired { + if _, err := identity.Ensure(home.Dir()); err != nil { + t.Fatal(err) + } + } cfg.Workspace, cfg.SessionFile = cfg.Place.Workspace, cfg.Place.Transcript() cfg.Model, cfg.APIKey, cfg.BaseURL = "stub/chat", "stub", "http://127.0.0.1:1" agent, err := session.New(cfg) @@ -118,7 +128,7 @@ func TestDriveSideOffIsStageZero(t *testing.T) { // With a relay set the door notes every sealed turn for it, and closing the // door gives the lease back after publishing what was sealed. func TestDriveSideDoorPublishesAndReleases(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler) t.Cleanup(srv.Close) if _, err := identity.Ensure(home.Dir()); err != nil { @@ -158,8 +168,8 @@ func TestSayRefusalOnlyForRefusals(t *testing.T) { } } -// A chat that opened before this computer had an identity starts to sync the -// moment /pair makes one, with no restart: the next call's seal is published and +// A chat that opened before this computer was paired starts to sync the +// moment /pair engages its identity, with no restart: the next call's seal is published and // closing the chat gives the lease back, exactly as for a chat that was paired // from the start. func TestDriveSideStartsWhenAPairingMakesTheIdentity(t *testing.T) { @@ -173,7 +183,7 @@ func TestDriveSideStartsWhenAPairingMakesTheIdentity(t *testing.T) { if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { t.Fatal(err) } - if _, err := identity.Ensure(home.Dir()); err != nil { // what /pair does in the running chat + if err := identity.EndSolo(home.Dir()); err != nil { // pairing engages an existing local identity t.Fatal(err) } if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { @@ -285,7 +295,7 @@ func TestStartDriveWithNoIdentityReportsNothing(t *testing.T) { // its own. The drive side kept from before the move only shows the chat, so reusing it would // refuse every tool call in the chat that was moved back. func TestAChatMovedBackGetsADriveSideOfItsOwn(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) srv, published, closeRelay := driveRelayWithPublication(t, c.ID) defer closeRelay() t.Setenv(syncsetup.URLVar, srv.URL) @@ -354,7 +364,7 @@ func TestAChatMovedBackGetsADriveSideOfItsOwn(t *testing.T) { // drive side this window kept, opens with a drive side of its own: the old one publishes under a // lease that is gone, so reusing it would refuse every tool call in the chat that came back. func TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) srv, published, closeRelay := driveRelayWithPublication(t, c.ID) defer closeRelay() t.Setenv(syncsetup.URLVar, srv.URL) diff --git a/internal/agentdid/credentials_test.go b/internal/agentdid/credentials_test.go index e13e40d84a..cb5c352ca1 100644 --- a/internal/agentdid/credentials_test.go +++ b/internal/agentdid/credentials_test.go @@ -180,3 +180,18 @@ func TestCredentialsRejectMalformedEnvelopesAndSupportHierarchicalPurposes(t *te } } } + +func TestCustodyEncodingCannotLookLikeAnAbsolutePath(t *testing.T) { + original := custody{255, 255, 255, 255} + raw, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + if bytes.ContainsAny(raw, "/+=") { + t.Fatalf("custody is not URL-safe: %s", raw) + } + var restored custody + if err := json.Unmarshal(raw, &restored); err != nil || !bytes.Equal(original, restored) { + t.Fatalf("round trip: %v", err) + } +} diff --git a/internal/agentdid/document.go b/internal/agentdid/document.go index ecc2e5674e..1507061ccb 100644 --- a/internal/agentdid/document.go +++ b/internal/agentdid/document.go @@ -2,6 +2,7 @@ package agentdid import ( "bytes" + "encoding/base64" "encoding/json" "errors" ) @@ -18,17 +19,37 @@ type Key struct { // Document keeps public keys and encrypted custody together as sealed truth. // Unknown documents retain their original bytes, including future statements. type Document struct { - V string `json:"v,omitempty"` - DID string `json:"did"` - Session string `json:"session"` - Signing *Key `json:"signing,omitempty"` - Encryption *Key `json:"encryption,omitempty"` - Custody []byte `json:"custody,omitempty"` - SigningUnavailable bool `json:"signing_unavailable,omitempty"` + V string `json:"v,omitempty"` + DID string `json:"did"` + Session string `json:"session"` + Signing *Key `json:"signing,omitempty"` + Encryption *Key `json:"encryption,omitempty"` + Custody custody `json:"custody,omitempty"` + SigningUnavailable bool `json:"signing_unavailable,omitempty"` pending *Credentials raw json.RawMessage } +// custody uses URL-safe encoding so opaque ciphertext never resembles a path. +type custody []byte + +func (c custody) MarshalJSON() ([]byte, error) { + return json.Marshal(base64.RawURLEncoding.EncodeToString(c)) +} + +func (c *custody) UnmarshalJSON(raw []byte) error { + var encoded string + if err := json.Unmarshal(raw, &encoded); err != nil { + return err + } + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return err + } + *c = decoded + return nil +} + type documentWire Document // UnmarshalJSON reads routing fields while retaining the complete document. diff --git a/internal/session/agent_identity.go b/internal/session/agent_identity.go index c4f261f27a..fa7d4e6c5e 100644 --- a/internal/session/agent_identity.go +++ b/internal/session/agent_identity.go @@ -119,9 +119,9 @@ func protectAgentIdentity(doc *AgentIdentity) (*AgentIdentity, error) { if doc == nil || !doc.NeedsProtection() { return doc, nil } - // Pairing policy belongs to the launcher. A custody write must neither - // mark a profile solo nor change an existing profile's sync posture. - root, err := identity.Ensure(home.Dir()) + // Like the vault, custody creates a quiet local profile on first use. + // Existing paired or solo profiles retain their sync posture. + root, err := identity.EnsureSolo(home.Dir()) if err != nil { return nil, err } diff --git a/internal/session/agent_identity_custody_test.go b/internal/session/agent_identity_custody_test.go index fcefe156c4..0e035a5b10 100644 --- a/internal/session/agent_identity_custody_test.go +++ b/internal/session/agent_identity_custody_test.go @@ -228,15 +228,17 @@ func TestAgentIdentityRestoringSavedDialsDoesNotMigrateOnOpen(t *testing.T) { } } -func TestAgentIdentityCustodyDoesNotChooseTheProfilesSyncPosture(t *testing.T) { +func TestAgentIdentityCustodyPreservesExistingProfilesSyncPosture(t *testing.T) { for _, solo := range []bool{false, true} { t.Run(map[bool]string{false: "unmarked", true: "solo"}[solo], func(t *testing.T) { profile := t.TempDir() t.Setenv(home.EnvVar, profile) + ensure := identity.Ensure if solo { - if _, err := identity.EnsureSolo(profile); err != nil { - t.Fatal(err) - } + ensure = identity.EnsureSolo + } + if _, err := ensure(profile); err != nil { + t.Fatal(err) } agent := openIdentityAgent(t, identityPlace(t, "sync-posture"), nil) agent.stampMeta() @@ -246,3 +248,13 @@ func TestAgentIdentityCustodyDoesNotChooseTheProfilesSyncPosture(t *testing.T) { }) } } + +func TestAgentIdentityCustodyMakesAFreshProfileQuiet(t *testing.T) { + profile := t.TempDir() + t.Setenv(home.EnvVar, profile) + agent := openIdentityAgent(t, identityPlace(t, "fresh-profile"), nil) + agent.stampMeta() + if !identity.Solo(profile) { + t.Fatal("custody made a fresh profile eligible for sync") + } +} From 56f180897c6cab0c79f49581dfd73ff92f03b630 Mon Sep 17 00:00:00 2001 From: Santosh kumar <29346072+santoshkumarradha@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:15:39 -0400 Subject: [PATCH 9/9] ci: keep one-off base diagnostics out of PR acceptance --- .github/workflows/agent-identity.yml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/.github/workflows/agent-identity.yml b/.github/workflows/agent-identity.yml index 14d457cb66..78f8e01eae 100644 --- a/.github/workflows/agent-identity.yml +++ b/.github/workflows/agent-identity.yml @@ -83,26 +83,3 @@ jobs: env: BASE: ${{ github.event.pull_request.base.sha }} run: make test-touched - - baseline: - name: Compare existing failures on the untouched base - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.sha }} - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - name: Build the untouched base - run: make build - - name: Probe command failures on the base - run: go test ./cmd/codeaf -run '^(TestAppChatRowsR2SavedTurnTime|TestAppChatStartupR3QuietRelease|TestAppTakeC1bSavedModelTravelsBack|TestC1AppViewerAcceptsRealPendingConsent|TestC1C2AppViewerRefusesWorkBeforeSubmission|TestRecipeDoorWritesTheWorktreeWhenOnlyTheCheckoutIsHeld|TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable|TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat|TestHostedTakeBackReachesAcquireWithoutSharedUploadsOrReleaseDelay|TestDriveSideDoorPublishesAndReleases|TestDriveSideStartsWhenAPairingMakesTheIdentity|TestAChatMovedBackGetsADriveSideOfItsOwn|TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn)$' -count=1 -v -timeout 5m - - name: Probe session failures on the base - if: ${{ !cancelled() }} - run: go test ./internal/session -run '^TestTheFixedPrefixStaysUnderItsBudget$|^TestTheLeanPrefixStaysUnderItsBudget$|^TestATurnThatMustReportRequiresTheVerbOnItsFirstRequestOnly$|^TestALandStartsNoAutomaticMaintenance$|^TestBashBeltTruncationCapFollowsTheWindow$|^TestTheStageVerbsCostOnlyTheStageAndStayUnderTheirBudget$|^TestBashBeltTruncationWritesHeadTailAndPath$|^TestProgramHoldClassifiesEveryRegisteredChatTool$|^TestNoRoadInTheMemoryEngineHasMoreEndingsThanTheCeiling$|^TestNoRoadInTheTaskEngineHasMoreEndingsThanItsLedgerRow$' -count=1 -v -timeout 5m - - name: Compare the intermittent takeover assertion on the base - if: ${{ !cancelled() }} - run: go test ./internal/session -run '^TestAWithdrawnOrStaleTakeoverIsIgnored$' -count=100 -v -timeout 5m