| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/config" |
| 13 | "reasonix/internal/provider" |
| 14 | ) |
| 15 | |
| 16 | func TestSessionMachineListIsStableAndRedacted(t *testing.T) { |
| 17 | identityKey := installMachineTestIdentity(t) |
| 18 | dir := t.TempDir() |
| 19 | saveMachineTestSession(t, dir, "older", time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)) |
| 20 | saveMachineTestSession(t, dir, "newer", time.Date(2026, 7, 23, 11, 0, 0, 0, time.UTC)) |
| 21 | |
| 22 | var out bytes.Buffer |
| 23 | if code := runSessionCommand([]string{"list", "--dir", dir, "--json"}, &out); code != 0 { |
| 24 | t.Fatalf("list exit code = %d, output = %s", code, out.String()) |
| 25 | } |
| 26 | var response machineSessionList |
| 27 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 28 | t.Fatalf("decode list: %v", err) |
| 29 | } |
| 30 | if response.SchemaVersion != machineSchemaVersion || response.Command != "session.list" { |
| 31 | t.Fatalf("header = %+v", response) |
| 32 | } |
| 33 | if len(response.Sessions) != 2 { |
| 34 | t.Fatalf("sessions = %+v, want two sessions", response.Sessions) |
| 35 | } |
| 36 | if got := response.Sessions[0].ID; got != machineSessionIDWithKey("newer", identityKey) { |
| 37 | t.Errorf("first session = %q, want opaque newer id", got) |
| 38 | } |
| 39 | if got := response.Sessions[1].ID; got != machineSessionIDWithKey("older", identityKey) { |
| 40 | t.Errorf("second session = %q, want opaque older id", got) |
| 41 | } |
| 42 | if response.Sessions[0].Turns != 1 || response.Sessions[0].Scope != "project" { |
| 43 | t.Errorf("session metadata = %+v", response.Sessions[0]) |
| 44 | } |
| 45 | if strings.Contains(out.String(), "PRIVATE") || strings.Contains(out.String(), dir) { |
| 46 | t.Fatalf("machine output leaked private content or path: %s", out.String()) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestMachineProjectSessionDirUsesProjectStore(t *testing.T) { |
| 51 | projectRoot := t.TempDir() |
| 52 | got := machineProjectSessionDir(projectRoot) |
| 53 | want := config.ProjectSessionDir(projectRoot) |
| 54 | if got != want { |
| 55 | t.Fatalf("machine project session dir = %q, want %q", got, want) |
| 56 | } |
| 57 | if got == projectRoot { |
| 58 | t.Fatalf("machine project session dir must not use the project root directly: %q", got) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestSessionMachineProjectRootUsesProjectStore(t *testing.T) { |
| 63 | identityKey := installMachineTestIdentity(t) |
| 64 | projectRoot := t.TempDir() |
| 65 | sessionDir := config.ProjectSessionDir(projectRoot) |
| 66 | saveMachineTestSession(t, sessionDir, "project", time.Date(2026, 7, 23, 11, 30, 0, 0, time.UTC)) |
| 67 | |
| 68 | var out bytes.Buffer |
| 69 | if code := runSessionCommand([]string{"list", "--json", "--project-root", projectRoot}, &out); code != 0 { |
| 70 | t.Fatalf("list exit code = %d, output = %s", code, out.String()) |
| 71 | } |
| 72 | var response machineSessionList |
| 73 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 74 | t.Fatalf("decode list: %v", err) |
| 75 | } |
| 76 | if len(response.Sessions) != 1 || response.Sessions[0].ID != machineSessionIDWithKey("project", identityKey) { |
| 77 | t.Fatalf("sessions = %+v, want project session", response.Sessions) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func TestSessionMachineShowAndStatusExposeOnlySafeState(t *testing.T) { |
| 82 | identityKey := installMachineTestIdentity(t) |
| 83 | dir := t.TempDir() |
| 84 | saveMachineTestSession(t, dir, "busy", time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)) |
| 85 | path := filepath.Join(dir, "busy.jsonl") |
| 86 | lease, err := agent.TryAcquireSessionLease(path) |
| 87 | if err != nil { |
| 88 | t.Fatalf("acquire session lease: %v", err) |
| 89 | } |
| 90 | defer lease.Release() |
| 91 | |
| 92 | for _, operation := range []string{"show", "status"} { |
| 93 | var out bytes.Buffer |
| 94 | args := []string{operation, "--json", machineSessionIDWithKey("busy", identityKey), "--dir", dir} |
| 95 | if code := runSessionCommand(args, &out); code != 0 { |
| 96 | t.Fatalf("%s exit code = %d, output = %s", operation, code, out.String()) |
| 97 | } |
| 98 | var response machineSessionShow |
| 99 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 100 | t.Fatalf("decode %s: %v", operation, err) |
| 101 | } |
| 102 | if response.Command != "session."+operation || response.Session.State != "active" { |
| 103 | t.Errorf("%s response = %+v", operation, response) |
| 104 | } |
| 105 | if strings.Contains(out.String(), dir) || strings.Contains(out.String(), "PRIVATE") { |
| 106 | t.Fatalf("%s output leaked private data: %s", operation, out.String()) |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestMachineSessionIDIsStableAndOpaque(t *testing.T) { |
| 112 | raw := "20260723-120000.000000000-private-provider-model" |
| 113 | identityKey := bytes.Repeat([]byte{0x41}, machineIdentityKeyBytes) |
| 114 | otherKey := bytes.Repeat([]byte{0x42}, machineIdentityKeyBytes) |
| 115 | first := machineSessionIDWithKey(raw, identityKey) |
| 116 | if first == "" || first != machineSessionIDWithKey(raw, identityKey) { |
| 117 | t.Fatalf("machine session id is not stable: %q", first) |
| 118 | } |
| 119 | if strings.Contains(first, "private-provider-model") || first == raw { |
| 120 | t.Fatalf("machine session id exposed raw branch identity: %q", first) |
| 121 | } |
| 122 | if first == machineSessionIDWithKey(raw+"-other", identityKey) { |
| 123 | t.Fatalf("different branch ids collided: %q", first) |
| 124 | } |
| 125 | if first == machineSessionIDWithKey(raw, otherKey) { |
| 126 | t.Fatalf("different installations produced the same machine id: %q", first) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestSessionMachineErrorsAreJSONAndNonZero(t *testing.T) { |
| 131 | installMachineTestIdentity(t) |
| 132 | dir := t.TempDir() |
| 133 | cases := []struct { |
| 134 | name string |
| 135 | args []string |
| 136 | code int |
| 137 | err string |
| 138 | }{ |
| 139 | {name: "missing json", args: []string{"list", "--dir", dir}, code: 2, err: "invalid_argument"}, |
| 140 | {name: "missing session", args: []string{"show", "--json", "missing", "--dir", dir}, code: 1, err: "session_not_found"}, |
| 141 | {name: "conflicting session sources", args: []string{"list", "--json", "--dir", dir, "--project-root", dir}, code: 2, err: "invalid_argument"}, |
| 142 | } |
| 143 | for _, tc := range cases { |
| 144 | t.Run(tc.name, func(t *testing.T) { |
| 145 | var out bytes.Buffer |
| 146 | if code := runSessionCommand(tc.args, &out); code != tc.code { |
| 147 | t.Fatalf("exit code = %d, want %d; output = %s", code, tc.code, out.String()) |
| 148 | } |
| 149 | var response machineErrorResponse |
| 150 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 151 | t.Fatalf("decode error: %v", err) |
| 152 | } |
| 153 | if response.SchemaVersion != machineSchemaVersion || response.Error.Code != tc.err { |
| 154 | t.Fatalf("response = %+v", response) |
| 155 | } |
| 156 | if strings.Contains(out.String(), dir) { |
| 157 | t.Fatalf("error output leaked path: %s", out.String()) |
| 158 | } |
| 159 | }) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func saveMachineTestSession(t *testing.T, dir, id string, updatedAt time.Time) { |
| 164 | t.Helper() |
| 165 | path := filepath.Join(dir, id+".jsonl") |
| 166 | session := agent.NewSession("") |
| 167 | session.Add(provider.Message{Role: provider.RoleUser, Content: "private prompt"}) |
| 168 | session.Add(provider.Message{Role: provider.RoleAssistant, Content: "private answer"}) |
| 169 | if err := session.Save(path); err != nil { |
| 170 | t.Fatalf("save session: %v", err) |
| 171 | } |
| 172 | if err := agent.SaveBranchMeta(path, agent.BranchMeta{ |
| 173 | ID: id, |
| 174 | CreatedAt: updatedAt.Add(-time.Hour), |
| 175 | UpdatedAt: updatedAt, |
| 176 | Scope: "project", |
| 177 | SchemaVersion: agent.BranchMetaCountsVersion, |
| 178 | Turns: 1, |
| 179 | Preview: "PRIVATE prompt", |
| 180 | }); err != nil { |
| 181 | t.Fatalf("save branch meta: %v", err) |
| 182 | } |
| 183 | } |
| 184 |