fix(copilot-cli): guard float timestamp edge cases from review · Entire

fix(copilot-cli): guard float timestamp edge cases from review

a170a8d→main·

gtrrz-victor·18h ago·2 files·+54 added/-18 removed

Truncate before the zero check so sub-millisecond floats (e.g. 0.4) are treated as missing rather than the Unix epoch, reject numeric timestamps outside the int64 range instead of relying on implementation-defined float conversion, document the truncation on the exported func, and assert exact epoch-millis in the envelope fixtures.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Changes

2

3 unmodified lines
4
5
6
7
8
9
10
118 unmodified lines

129
130
131
131
132
133
134
132
133
134
135
136
137
138
1 unmodified line

140
141
142
142
143
144
145
146
147
148
149
150
151
145
152
153
154
155

3 unmodified lines

"encoding/json"
    "errors"
    "fmt"
    "math"
    "slices"
    "time"
)
118 unmodified lines

// ParseTimestamp decodes a Copilot event timestamp, which may be either numeric
// epoch-millis or an RFC3339(Nano) string. The numeric form may carry a
// fractional part (Copilot CLI 1.0.71 emits e.g. 1784283185447.0). A null/zero
// value returns the zero time (callers treat that as "missing"). Exported so
// transcript importers can decode the same dual-format field without
// re-implementing the logic.
// fractional part (Copilot CLI 1.0.71 emits e.g. 1784283185447.0); sub-millisecond
// precision is truncated. A null/zero value returns the zero time (callers treat
// that as "missing"). Exported so transcript importers can decode the same
// dual-format field without re-implementing the logic.
func ParseTimestamp(raw json.RawMessage) (time.Time, error) {
    if len(raw) == 0 || string(raw) == "null" {
        return time.Time{}, nil
    }

var millis float64
    if err := json.Unmarshal(raw, &millis); err == nil {
        if millis == 0 {
            // Guard the float→int64 conversion: out-of-range values are
            // implementation-defined in Go, so reject them instead.
            if millis >= float64(math.MaxInt64) || millis <= float64(math.MinInt64) {
                return time.Time{}, fmt.Errorf("timestamp %v out of range", millis)
            }
            ns := int64(millis) // truncates any fractional milliseconds
            if ns == 0 {
                return time.Time{}, nil // Treat epoch as missing — triggers time.Now() fallback.
            }
            return time.UnixMilli(int64(millis)), nil
            return time.UnixMilli(ns), nil
        }

var ts string

Mcmd/entire/cli/agent/copilotcli/compat.go+13/-6

155 unmodified lines

156
157
158
159
160
159
160
161
162
163
163
164
164
165
166
167
168
167
168
169
170
171
172
173
171
172
174
175
176
177
178
175
176
179
180
181
182
183
184
8 unmodified lines

193
194
195
191
192
196
197
198
199
200
14 unmodified lines

215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244

155 unmodified lines

t.Parallel()

tests := []struct {
    name string
    raw  string
    name       string
    raw        string
    wantMillis int64
}{
    {
        name: "sessionStart",
        raw:  `{"sessionId":"sess-123","timestamp":1784283185447.0,"cwd":"/tmp/repo","source":"new","initialPrompt":"hi"}`,
        name:       "sessionStart",
        raw:        `{"sessionId":"sess-123","timestamp":1784283185447.0,"cwd":"/tmp/repo","source":"new","initialPrompt":"hi"}`,
        wantMillis: 1784283185447,
    },
    {
        name: "userPromptSubmitted",
        raw:  `{"sessionId":"sess-123","timestamp":1784283185370.0,"cwd":"/tmp/repo","prompt":"hi"}`,
        name:       "userPromptSubmitted",
        raw:        `{"sessionId":"sess-123","timestamp":1784283185370.0,"cwd":"/tmp/repo","prompt":"hi"}`,
        wantMillis: 1784283185370,
    },
    {
        name: "agentStop",
        raw:  `{"sessionId":"sess-123","timestamp":1784283190710.0,"cwd":"/tmp/repo","transcriptPath":"/tmp/events.jsonl","stopReason":"end_turn"}`,
        name:       "agentStop",
        raw:        `{"sessionId":"sess-123","timestamp":1784283190710.0,"cwd":"/tmp/repo","transcriptPath":"/tmp/events.jsonl","stopReason":"end_turn"}`,
        wantMillis: 1784283190710,
    },
    {
        name: "sessionEnd",
        raw:  `{"sessionId":"sess-123","timestamp":1784283190784.0,"cwd":"/tmp/repo","reason":"complete"}`,
        name:       "sessionEnd",
        raw:        `{"sessionId":"sess-123","timestamp":1784283190784.0,"cwd":"/tmp/repo","reason":"complete"}`,
        wantMillis: 1784283190784,
    },
}

8 unmodified lines

if env.Host != HostCopilotCLI {
            t.Fatalf("Host = %q, want %q", env.Host, HostCopilotCLI)
        }
        if got := env.Timestamp.UnixMilli(); got < 1784283185000 || got > 1784283191000 {
            t.Fatalf("Timestamp.UnixMilli() = %d, want the payload's epoch-millis value", got)
        if got := env.Timestamp.UnixMilli(); got != tt.wantMillis {
            t.Fatalf("Timestamp.UnixMilli() = %d, want %d", got, tt.wantMillis)
        }
        if env.SessionID != "sess-123" {
            t.Fatalf("SessionID = %q, want %q", env.SessionID, "sess-123")
        }
14 unmodified lines

}

func TestParseTimestamp_SubMillisecondFloatIsMissing(t *testing.T) {
    t.Parallel()

// 0.4 truncates to 0 ms — must be treated as "missing" (zero time),
    // not as the Unix epoch.
    ts, err := ParseTimestamp(json.RawMessage(`0.4`))
    if err != nil {
        t.Fatalf("ParseTimestamp() error = %v", err)
    }
    if !ts.IsZero() {
        t.Fatalf("ParseTimestamp(0.4) = %v, want zero time", ts)
    }
}

func TestParseTimestamp_OutOfRangeFloatErrors(t *testing.T) {
    t.Parallel()

for _, raw := range []string{`1e300`, `-1e300`} {
        if _, err := ParseTimestamp(json.RawMessage(raw)); err == nil {
            t.Fatalf("ParseTimestamp(%s) expected out-of-range error, got nil", raw)
        }
    }
}

func TestDetectHookHost_FloatTimestampIsCopilotCLI(t *testing.T) {
    t.Parallel()