Notify Tentacles when pending Remote approval is revoked

This commit is contained in:
ak47 committed 2026-11-04 14:43:22 -05:00
1 parent 205cb27483
commit 9bc74dbb13
8 files changed
+61 -12

No files matched your search

+4 -2
View File
@@ -154,9 +154,11 @@ Its sequence starts at one and increases exactly once per event. Supported kinds
- `remote.action`: sequence, kind, action token, host-issued epoch, role and approved - `remote.action`: sequence, kind, action token, host-issued epoch, role and approved
grant subset. Created only by the internal core authorizer. Approval expires grant subset. Created only by the internal core authorizer. Approval expires
after 30 seconds if not consumed. No network secret is included. after 30 seconds if not consumed. No network secret is included.
- `remote.revoked`: sequence, kind, lease, epoch and reason (`closed`, `expired`, - `remote.revoked`: sequence, kind, epoch, exactly one of action or lease, and reason (`closed`, `expired`,
`local`, `replaced`). Failure to enqueue revocation retires the session; it `local`, `replaced`). Failure to enqueue revocation retires the session; it
cannot block immediate cancellation. An unconsumed approval expires silently. cannot block immediate cancellation. Pending approval expiry/revocation carries
its action token; an opened lease carries its lease token. Both retire external
setup workers through the same epoch-bound notification.
Child calls: Child calls:
+2 -2
View File
@@ -43,8 +43,8 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
defer stopCommand() defer stopCommand()
stop := context.AfterFunc(command.session.OutputContext(), cancel) stop := context.AfterFunc(command.session.OutputContext(), cancel)
defer stop() defer stop()
// Policy cancellation can interrupt setup before a lease exists, when no // Policy cancellation can interrupt setup before approval exists.
// remote.revoked event is emitted. Tell the original continuation to stop its // Tell the original continuation to stop its
// inspection/setup workers before releasing the interaction slot. Never pass // inspection/setup workers before releasing the interaction slot. Never pass
// cancelled form values or reuse the cancelled interaction context. // cancelled form values or reuse the cancelled interaction context.
cleanupNext := "" cleanupNext := ""
+2 -2
View File
@@ -182,8 +182,8 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
access.timer.Stop() access.timer.Stop()
} }
session.release_remote_owner() session.release_remote_owner()
if notify && access.lease != "" { if notify {
event := tentaclewire.SessionEvent{Kind: "remote.revoked", Lease: access.lease, Epoch: access.epoch, Reason: reason} event := tentaclewire.SessionEvent{Kind: "remote.revoked", Action: access.action, Lease: access.lease, Epoch: access.epoch, Reason: reason}
if !session.remote_event_locked(&event) { if !session.remote_event_locked(&event) {
session.fail_locked(ErrLimit) session.fail_locked(ErrLimit)
} }
+4 -4
View File
@@ -75,10 +75,10 @@ func TestRemotePolicyRevokesPendingAndLiveSDKAccess(t *testing.T) {
if _, code := remoteCall(t, session, "remote.heartbeat", tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}); code != -32001 { if _, code := remoteCall(t, session, "remote.heartbeat", tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}); code != -32001 {
t.Fatal("heartbeat revived lease", code) t.Fatal("heartbeat revived lease", code)
} }
var event tentaclewire.SessionEvent }
if json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event) != nil || event.Kind != "remote.revoked" || event.Lease != lease.Lease { var event tentaclewire.SessionEvent
t.Fatal("revocation event", event) if json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event) != nil || event.Kind != "remote.revoked" || event.Epoch != action.Epoch || event.Lease != lease.Lease || (!opened && event.Action != action.Action) {
} t.Fatal("revocation event", event)
} }
} }
// A fresh approval can replace the cancelled epoch even if its // A fresh approval can replace the cancelled epoch even if its
+30
View File
@@ -470,3 +470,33 @@ func TestSessionAggregateQueueBound(t *testing.T) {
t.Fatal("aggregate byte ceiling exceeded") t.Fatal("aggregate byte ceiling exceeded")
} }
} }
func TestPendingRemoteRevocationReachesBothSDKs(t *testing.T) {
for _, language := range []string{"go", "python"} {
for _, reason := range []string{"local", "expired", "replaced"} {
t.Run(language+"/"+reason, func(t *testing.T) {
session := startSessionSDK(t, test_manager(t, DefaultLimits(), nil), language, "pending-revoke")
action, err := session.AuthorizeRemote("controller", []string{"remote-present"})
if err != nil {
t.Fatal(err)
}
invokeSessionSDK(t, session, "event", nil)
session.mutex.Lock()
if reason == "expired" {
session.refresh_remote_locked(time.Millisecond)
} else {
session.revoke_remote_locked(reason, true)
}
session.mutex.Unlock()
var event tentaclewire.SessionEvent
raw := invokeSessionSDK(t, session, "event", nil)
if json.Unmarshal(raw, &event) != nil || event.Kind != "remote.revoked" || event.Action != action.Action || event.Lease != "" || event.Epoch != action.Epoch || event.Reason != reason {
t.Fatal("pending action revocation was not delivered", string(raw))
}
if _, code := remoteCall(t, session, "remote.open", tentaclewire.RemoteOpen{Action: action.Action, Epoch: action.Epoch, Role: action.Role, Grants: action.Grants}); code != -32001 {
t.Fatal("retired action opened a lease", code)
}
})
}
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ func ValidSessionEvent(event SessionEvent) bool {
case "remote.action": case "remote.action":
return event.Speech == nil && len(event.Action) == 32 && event.Lease == "" && event.Reason == "" && ValidRoleGrants(event.Role, event.Grants) return event.Speech == nil && len(event.Action) == 32 && event.Lease == "" && event.Reason == "" && ValidRoleGrants(event.Role, event.Grants)
case "remote.revoked": case "remote.revoked":
return event.Speech == nil && event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && slices.Contains([]string{"closed", "expired", "local", "replaced"}, event.Reason) return event.Speech == nil && ((event.Action == "" && len(event.Lease) == 32) || (len(event.Action) == 32 && event.Lease == "")) && event.Role == "" && len(event.Grants) == 0 && slices.Contains([]string{"closed", "expired", "local", "replaced"}, event.Reason)
case "remote.output": case "remote.output":
return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech != nil && ValidRemoteSpeech(*event.Speech) return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech != nil && ValidRemoteSpeech(*event.Speech)
} }
+15
View File
@@ -33,3 +33,18 @@ func TestSessionNegotiationRequiresIndependentGrants(t *testing.T) {
}) })
} }
} }
func TestRemoteRevocationIdentifiesExactlyOneActionOrLease(t *testing.T) {
token := "0123456789abcdef0123456789abcdef"
for _, item := range []struct {
action, lease string
valid bool
}{
{token, "", true}, {"", token, true}, {token, token, false}, {"", "", false}, {"short", "", false},
} {
event := SessionEvent{Sequence: 1, Epoch: 1, Kind: "remote.revoked", Reason: "expired", Action: item.action, Lease: item.lease}
if ValidSessionEvent(event) != item.valid {
t.Fatalf("unexpected validation for %+v", item)
}
}
}
+3 -1
View File
@@ -305,7 +305,9 @@ class SessionClient:
or role == "controlled" and set(grants) & {"remote-present", "keyboard-capture"}): or role == "controlled" and set(grants) & {"remote-present", "keyboard-capture"}):
raise ProtocolError("Invalid action event") raise ProtocolError("Invalid action event")
elif event["kind"] == "remote.revoked": elif event["kind"] == "remote.revoked":
if ("speech" in event or not _token(event.get("lease")) or event.get("action", "") != "" or event.get("role", "") != "" identity = ((_token(event.get("lease")) and event.get("action", "") == "")
or (_token(event.get("action")) and event.get("lease", "") == ""))
if ("speech" in event or not identity or event.get("role", "") != ""
or event.get("grants", []) != [] or event.get("reason") not in ("closed", "expired", "local", "replaced")): or event.get("grants", []) != [] or event.get("reason") not in ("closed", "expired", "local", "replaced")):
raise ProtocolError("Invalid revoke event") raise ProtocolError("Invalid revoke event")
elif event["kind"] == "remote.output": elif event["kind"] == "remote.output":