Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ Versioning convention (see [README.md](./README.md) for full policy):

- `MsgReplayRequest`, `MsgReplayComplete` constants for durable journal recovery
- `ReplayRequest`, `ReplayCompletePayload`
- `CtrlPushNotifyResult` control message for relay-to-daemon Web Push delivery results
- `PushNotifyPayload` tracking fields: `notification_id`, `trace_id`, `created_at_unix_ms`, `attempt`
- `PushNotifyResultPayload` for accepted/retryable/permanent Web Push result acknowledgements

## [v0.1.0] — 2026-04-29

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ A tiny Go module containing the shared wire protocol types used by both the [Age
| File | Types | Purpose |
|------|-------|---------|
| `envelope.go` | `RelayEnvelope` | Encrypted message envelope (sid, seq, enc, tid) |
| `control.go` | `ControlMessage`, `ControlType` (12 constants), 11 payload structs | Relay control protocol |
| `control.go` | `ControlMessage`, `ControlType` (16 constants), 15 payload structs | Relay control protocol |
| `policy.go` | `PolicyJSON`, `PolicyMatchJSON` | Policy rule wire format |
| `capabilities.go` | `AgentCapability` | Per-agent feature flags the daemon emits to the PWA (MCP reconnect, session-scoped approvals, free-text replies, etc.) |
| `codex_sandbox.go` | `CodexSandboxMode` | Shared Codex per-session sandbox literals for daemon and PWA runtime controls |
Expand All @@ -37,7 +37,8 @@ A tiny Go module containing the shared wire protocol types used by both the [Age
```
register · join · heartbeat · ack · error · sync_policies
status_update · audit_entry · deactivate_developer · client_connected
client_count · key_rotate
client_count · key_rotate · entitlement_update · entitlement_violation
push_notify · push_notify_result
```

### Payload Types
Expand All @@ -46,7 +47,8 @@ client_count · key_rotate
RegisterPayload · JoinPayload · AckPayload · ErrorPayload
StatusUpdatePayload · AuditEntryPayload · DeactivateDeveloperPayload
ClientConnectedPayload · ClientCountPayload · KeyRotatePayload
SyncPoliciesPayload
SyncPoliciesPayload · EntitlementUpdatePayload · EntitlementViolationPayload
PushNotifyPayload · PushNotifyResultPayload
```

## Usage
Expand Down
30 changes: 27 additions & 3 deletions control.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
CtrlEntitlementUpdate ControlType = "entitlement_update"
CtrlEntitlementViolation ControlType = "entitlement_violation"
CtrlPushNotify ControlType = "push_notify"
CtrlPushNotifyResult ControlType = "push_notify_result"
)

// ControlMessage is the wire format for relay control protocol messages.
Expand Down Expand Up @@ -117,9 +118,32 @@ type ClientCountPayload struct {
// carry the relay session ID; the relay derives that from the registered daemon
// connection to prevent session spoofing.
type PushNotifyPayload struct {
NavSessionID string `json:"nav_session_id,omitempty"`
Type string `json:"type"`
Summary string `json:"summary"`
NavSessionID string `json:"nav_session_id,omitempty"`
Type string `json:"type"`
Summary string `json:"summary"`
NotificationID string `json:"notification_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
CreatedAtUnixMS int64 `json:"created_at_unix_ms,omitempty"`
Attempt int `json:"attempt,omitempty"`
}

const (
PushNotifyStatusAccepted = "accepted"
PushNotifyStatusRetryable = "retryable"
PushNotifyStatusPermanent = "permanent"
)

// PushNotifyResultPayload is sent by the relay to the daemon after a
// daemon-originated push notification request reaches the relay. It reports the
// relay/provider result so the daemon can stop or retry tracked attempts.
type PushNotifyResultPayload struct {
NotificationID string `json:"notification_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
Type string `json:"type"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
ProviderStatusCode int `json:"provider_status_code,omitempty"`
DeliveredAtUnixMS int64 `json:"delivered_at_unix_ms,omitempty"`
}

// KeyRotatePayload is sent by the daemon to the relay to update the session's
Expand Down
65 changes: 62 additions & 3 deletions protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,13 @@ func TestPushNotifyPayloadRoundtrip(t *testing.T) {
t.Parallel()

payload := protocol.PushNotifyPayload{
NavSessionID: "agent-session-1",
Type: "approval_request",
Summary: "AgentD needs your approval",
NavSessionID: "agent-session-1",
Type: "approval_request",
Summary: "AgentD needs your approval",
NotificationID: "push-123",
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
CreatedAtUnixMS: 1778915600000,
Attempt: 2,
}
raw, err := json.Marshal(payload)
if err != nil {
Expand Down Expand Up @@ -121,6 +125,60 @@ func TestPushNotifyPayloadRoundtrip(t *testing.T) {
}
}

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

raw := []byte(`{"nav_session_id":"agent-session-1","type":"question","summary":"AgentD has a question"}`)
var got protocol.PushNotifyPayload
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal legacy PushNotifyPayload: %v", err)
}
if got.NotificationID != "" || got.TraceID != "" || got.CreatedAtUnixMS != 0 || got.Attempt != 0 {
t.Fatalf("legacy optional fields = id:%q trace:%q created:%d attempt:%d, want zero values",
got.NotificationID, got.TraceID, got.CreatedAtUnixMS, got.Attempt)
}
if got.Type != "question" || got.Summary != "AgentD has a question" || got.NavSessionID != "agent-session-1" {
t.Fatalf("legacy payload decoded incorrectly: %+v", got)
}
}

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

payload := protocol.PushNotifyResultPayload{
NotificationID: "push-123",
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
Type: "question",
Status: protocol.PushNotifyStatusAccepted,
Reason: "provider_accepted",
ProviderStatusCode: 201,
DeliveredAtUnixMS: 1778915600123,
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal PushNotifyResultPayload: %v", err)
}
msg := protocol.ControlMessage{Type: protocol.CtrlPushNotifyResult, Payload: raw}
encoded, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal ControlMessage: %v", err)
}
var decoded protocol.ControlMessage
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("unmarshal ControlMessage: %v", err)
}
if decoded.Type != protocol.CtrlPushNotifyResult {
t.Fatalf("Type = %q, want %q", decoded.Type, protocol.CtrlPushNotifyResult)
}
var got protocol.PushNotifyResultPayload
if err := json.Unmarshal(decoded.Payload, &got); err != nil {
t.Fatalf("unmarshal PushNotifyResultPayload: %v", err)
}
if got != payload {
t.Fatalf("payload = %+v, want %+v", got, payload)
}
}

func TestRegisterPayloadRoundtrip(t *testing.T) {
t.Parallel()
original := protocol.RegisterPayload{
Expand Down Expand Up @@ -498,6 +556,7 @@ func TestControlTypeConstants(t *testing.T) {
protocol.CtrlEntitlementUpdate: "entitlement_update",
protocol.CtrlEntitlementViolation: "entitlement_violation",
protocol.CtrlPushNotify: "push_notify",
protocol.CtrlPushNotifyResult: "push_notify_result",
}
for ct, want := range expected {
if string(ct) != want {
Expand Down
Loading