Canonicalize relay mobile capability boundary

This commit is contained in:
rcourtman 2026-03-28 19:32:42 +00:00
parent 44c529ba92
commit 5f436f11af
16 changed files with 474 additions and 97 deletions

View file

@ -1979,16 +1979,11 @@
"name": "Relay infrastructure readiness",
"target_score": 9,
"current_score": 9,
"status": "partial",
"status": "target-met",
"completion": {
"state": "bounded-residual",
"summary": "Relay infrastructure is at the RC floor; the remaining residual is limited to the backend-owned mobile relay capability boundary.",
"tracking": [
{
"kind": "lane-followup",
"id": "relay-mobile-capability-boundary"
}
]
"state": "complete",
"summary": "Relay infrastructure now carries an explicit backend-owned mobile capability boundary: the dedicated Pulse Mobile relay credential is limited to the governed runtime route inventory instead of depending on scattered compatibility gates.",
"tracking": []
},
"blockers": [],
"subsystems": [
@ -2000,6 +1995,11 @@
"path": "docs/RELAY.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/relay_mobile_capability.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/relay",
@ -3138,19 +3138,6 @@
"storage-recovery"
],
"cross_repo": false
},
{
"id": "relay-mobile-capability-boundary",
"summary": "Narrow the dedicated backend-owned mobile relay capability to the minimum supported runtime route set and require any future route expansion to land as explicit governed relay surface work rather than incidental backend reach-through.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-03-28",
"lane_ids": [
"L7"
],
"subsystem_ids": [
"relay-runtime"
]
}
],
"coverage_gaps": [
@ -3421,7 +3408,21 @@
]
}
],
"work_claims": [],
"work_claims": [
{
"id": "codex-portal-lane-l3",
"agent_id": "codex-portal",
"summary": "Continue Playwright-driven portal redesign from built review",
"target_id": "v6-rc-stabilization",
"claimed_at": "2026-03-28T19:12:30Z",
"heartbeat_at": "2026-03-28T19:12:30Z",
"expires_at": "2026-03-28T21:12:30Z",
"work_item": {
"kind": "lane",
"id": "L3"
}
}
],
"open_decisions": [],
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
"resolved_decisions": [

View file

@ -123,7 +123,7 @@ management, and fleet control surfaces.
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the direct Proxmox workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, the split infrastructure install/reporting state owners, the split direct-node/discovery infrastructure settings owners plus their shared model, shared frontend install-command assembly, Proxmox setup/install API transport, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructureDirectConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`.
7. Preserve canonical token-lifecycle reads in shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows do not revoke a displayed relay pairing token after `lastUsedAt` proves that an already paired device is actively depending on that credential.
8. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows reuse the canonical mobile token route instead of reintroducing wildcard or browser-authored runtime token bundles.
9. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route gates in those same shared helpers so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership.
9. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership.
## Forbidden Paths
@ -218,6 +218,10 @@ websocket upgrades trust the cloud proxy only through explicit tenant
`PULSE_TRUSTED_PROXY_CIDRS` wiring, so first-session handoff and agent-facing
live activity surfaces do not degrade into reconnect loops when a hosted
workspace is opened through the control plane.
That same shared helper layer also now assumes the Pulse Mobile relay runtime
credential reaches only the explicit backend-owned route inventory, so
lifecycle-adjacent setup and install flows cannot accidentally widen the
paired-device credential just by touching neighboring `internal/api/` routes.
The same shared API runtime now also exposes dedicated unified-resource
timeline reads through `internal/api/resources.go` plus the bundled facet
history read used by the drawer, but those query surfaces remain owned by the

View file

@ -125,11 +125,13 @@ it. `frontend-modern/src/api/ai.ts`,
the AI product while also remaining canonical payload contract boundaries.
That same AI transport boundary now also defines the narrow Pulse Mobile
runtime compatibility rule: mobile relay credentials are minted with the
dedicated backend-owned `relay:mobile:access` scope, and only the governed
mobile runtime routes may accept that scope as a compatibility alias alongside
legacy `ai:chat` or `ai:execute` mobile tokens. Broader AI runtime surfaces
must stay on their canonical AI scopes instead of treating the mobile relay
capability as a general-purpose AI permission.
dedicated backend-owned `relay:mobile:access` scope, and only the explicit
route inventory in `internal/api/relay_mobile_capability.go` may accept that
scope as a compatibility alias alongside legacy `ai:chat` or `ai:execute`
mobile tokens. Broader AI runtime surfaces must stay on their canonical AI
scopes instead of treating the mobile relay capability as a general-purpose
AI permission, and any new mobile-compatible AI route must land by extending
that governed backend inventory and proof set in the same slice.
That same shared AI transport boundary now also owns hosted AI bootstrap.
When Pulse Cloud runs in hosted mode and no explicit `ai.enc` exists yet,
`internal/api/ai_hosted_runtime.go`, `internal/api/ai_handler.go`, and

View file

@ -114,10 +114,11 @@ Own canonical runtime payload shapes between backend and frontend.
26. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary.
27. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
28. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
29. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
30. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
31. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
32. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
29. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface.
30. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
31. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
32. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
33. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
33. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
34. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
35. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary.
@ -155,7 +156,7 @@ Own canonical runtime payload shapes between backend and frontend.
13. Keep Patrol status transport semantics explicit in that same AI handler layer: the Patrol status endpoint must carry machine-readable runtime availability such as blocked, running, disabled, active, or unavailable rather than asking frontend consumers to infer operator state from stale summaries or run history.
14. Keep Patrol quickstart transport semantics explicit as well: zero remaining quickstart credits are inventory data, not a standalone runtime-state override, so frontend consumers may only present the exhausted quickstart warning when the payload still reports `using_quickstart` or a runtime state that is blocked by quickstart exhaustion.
15. Keep Patrol intelligence summary transport semantics single-voiced: the canonical overall-health payload and Patrol run-history payload together must support one primary assessment plus one explicit verification explanation, and frontend consumers must not need to derive a second compact assessment or verification verdict row from the same payloads beneath the primary summary card.
16. Keep Pulse Mobile relay credential minting and permission ownership on backend ownership: `internal/api/router_routes_auth_security.go`, `internal/api/security_tokens.go`, `internal/api/auth.go`, `internal/api/router_routes_ai_relay.go`, and `frontend-modern/src/api/security.ts` may expose the canonical mobile runtime token creator and governed route gates, but browser callers must only consume that route and must not define the mobile runtime scope, compatibility gate list, or token-purpose metadata locally.
16. Keep Pulse Mobile relay credential minting and permission ownership on backend ownership: `internal/api/router_routes_auth_security.go`, `internal/api/security_tokens.go`, `internal/api/auth.go`, `internal/api/relay_mobile_capability.go`, `internal/api/router_routes_ai_relay.go`, and `frontend-modern/src/api/security.ts` may expose the canonical mobile runtime token creator and governed route gates, but browser callers must only consume that route and must not define the mobile runtime scope, compatibility gate list, route inventory, or token-purpose metadata locally.
17. Keep hosted tenant browser-session precedence on the shared auth boundary: `internal/api/auth.go`, `internal/api/contract_test.go`, and hosted tenant callers must treat a valid `pulse_session` as authoritative before any API-only token fallback or no-local-auth anonymous fallback, so cloud handoff can continue into protected hosted routes without flattening the operator back to `anonymous` or forcing a browser session through bearer-token-only mode after the tenant has minted API tokens.
18. Keep tenant settings-scope authorization aligned with org management: `internal/api/security_setup_fix.go`, `internal/api/contract_test.go`, and settings-bound hosted callers must allow the current non-default org owner/admin membership to exercise privileged tenant routes, rather than requiring a separate configured local admin identity after hosted handoff.
19. Keep mobile onboarding payload reads aligned with the server-owned relay-mobile credential: `internal/api/router_routes_ai_relay.go`, `internal/api/onboarding_handlers.go`, and `internal/api/contract_test.go` must allow the dedicated `relay:mobile:access` scope to reach the governed QR, deep-link, and connection-validation payloads without reintroducing a broader `settings:read` requirement for token-authenticated pairing clients.
@ -1732,14 +1733,6 @@ both sides instead of relying only on broad settings-surface coverage on the
security side: token settings changes must continue to carry the direct
`api-token-management-surface` API-contract proof together with the
security-side surface proof.
That same governed token-settings boundary also owns its operator-facing scope
reference path. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`
may expose the scope-reference URL consumed by the API token settings shell,
but that URL must resolve through the shared shipped-doc owner in
`frontend-modern/src/utils/docsLinks.ts` and the local
`/docs/CONFIGURATION.md` asset instead of hardcoding a GitHub `main` document
that can drift from the payload contract actually shipped with the running
build.
That same shared commercial API boundary now also owns the local trial-start
transport contract. `/api/license/trial/start` may allow a short human-scale
burst of retries while the hosted redirect handoff remains canonical, but once
@ -1851,7 +1844,8 @@ request is scoped to a non-default org, `internal/api/security_setup_fix.go`
must honor the org's owner/admin membership model for settings-bound routes
such as relay-mobile token minting, instead of requiring a separate configured
local admin username that hosted tenants do not carry.
The same onboarding boundary in `internal/api/router_routes_ai_relay.go` must
The same onboarding boundary in `internal/api/router_routes_ai_relay.go` and
`internal/api/relay_mobile_capability.go` must
also accept the dedicated `relay:mobile:access` scope for
`/api/onboarding/qr`, `/api/onboarding/validate`, and
`/api/onboarding/deep-link`, because those payloads are the canonical
@ -1866,10 +1860,13 @@ consume that same contract when deciding whether a displayed QR token can be
revoked or must be preserved as an already-used device credential. That same
contract now also owns backend-minted Pulse Mobile relay access tokens: the
server route, not the browser, defines the canonical dedicated
`relay:mobile:access` runtime scope, its backward-compatible server-side route
gates alongside legacy `ai:chat` and `ai:execute` mobile tokens, and the
token-purpose metadata. The pairing UI only consumes that server-owned
credential when requesting the onboarding payload.
`relay:mobile:access` runtime scope, the explicit route inventory in
`internal/api/relay_mobile_capability.go`, its backward-compatible
server-side route gates alongside legacy `ai:chat` and `ai:execute` mobile
tokens, and the token-purpose metadata. Route expansion for Pulse Mobile must
land by editing that backend-owned inventory plus its proofs, rather than by
sprinkling ad hoc compatibility checks across handlers. The pairing UI only
consumes that server-owned credential when requesting the onboarding payload.
That same shared backend API contract now also owns hosted relay bootstrap
reads. `internal/api/router.go`, `internal/api/onboarding_handlers.go`, and
`internal/api/relay_hosted_runtime.go` must derive `/api/settings/relay` and

View file

@ -441,6 +441,14 @@
"cloud-paid"
]
},
{
"path": "internal/api/relay_mobile_capability.go",
"rationale": "the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface",
"subsystems": [
"api-contracts",
"relay-runtime"
]
},
{
"path": "internal/api/resources.go",
"rationale": "the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary",
@ -3562,12 +3570,14 @@
"pulse-mobile:src/relay/"
],
"owned_files": [
"internal/api/relay_mobile_capability.go",
"internal/config/persistence_relay.go"
],
"verification": {
"allow_same_subsystem_tests": true,
"test_prefixes": [],
"exact_files": [
"internal/api/relay_mobile_capability_test.go",
"internal/config/persistence_relay_test.go",
"internal/relay/client_test.go",
"pulse-mobile:src/relay/__tests__/channel.test.ts",
@ -3607,6 +3617,19 @@
"internal/config/persistence_relay_test.go"
]
},
{
"id": "relay-mobile-capability-boundary",
"label": "relay mobile capability boundary proof",
"match_prefixes": [],
"match_files": [
"internal/api/relay_mobile_capability.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/api/relay_mobile_capability_test.go"
]
},
{
"id": "mobile-relay-runtime",
"label": "mobile relay runtime proof",

View file

@ -16,30 +16,33 @@
## Purpose
Own the desktop and mobile relay runtimes, their persisted relay state
boundaries, and the canonical reconnect, encryption, protocol, proxy, and
relay-trust behavior for Pulse instance bridging.
boundaries, the server-owned mobile relay capability boundary, and the
canonical reconnect, encryption, protocol, proxy, and relay-trust behavior
for Pulse instance bridging.
## Canonical Files
1. `internal/relay/client.go`
2. `internal/relay/protocol.go`
3. `internal/config/persistence_relay.go`
4. `pulse-mobile:src/relay/client.ts`
5. `pulse-mobile:src/relay/protocol.ts`
6. `pulse-mobile:src/relay/proxy.ts`
7. `pulse-mobile:src/relay/encryption.ts`
4. `internal/api/relay_mobile_capability.go`
5. `pulse-mobile:src/relay/client.ts`
6. `pulse-mobile:src/relay/protocol.ts`
7. `pulse-mobile:src/relay/proxy.ts`
8. `pulse-mobile:src/relay/encryption.ts`
## Shared Boundaries
1. None.
1. `internal/api/relay_mobile_capability.go` shared with `api-contracts`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface.
## Extension Points
1. Add or change desktop relay reconnect, registration, drain, proxy-stream, or encrypted channel behavior through `internal/relay/`
2. Add or change relay control payload schemas, including mobile-visible push notification metadata, through `internal/relay/protocol.go`
3. Add or change persisted relay enablement, server URL, or reconnect-safe default loading through `internal/config/persistence_relay.go`
4. Add or change mobile relay reconnect, drain, channel, encryption, proxy, or identity behavior through `pulse-mobile:src/relay/`
5. Keep desktop and mobile relay changes aligned with the governed server relay surfaces represented by the L7 lane evidence
4. Add or change the backend-owned mobile relay capability inventory and compatibility scope mapping through `internal/api/relay_mobile_capability.go`
5. Add or change mobile relay reconnect, drain, channel, encryption, proxy, or identity behavior through `pulse-mobile:src/relay/`
6. Keep desktop and mobile relay changes aligned with the governed server relay surfaces represented by the L7 lane evidence
## Forbidden Paths
@ -52,7 +55,8 @@ relay-trust behavior for Pulse instance bridging.
1. Update this contract when new desktop relay runtime or persistence entry points become canonical
2. Keep relay client changes tied to explicit runtime proof in `internal/relay/client_test.go`
3. Keep persisted relay config loading tied to explicit proof in `internal/config/persistence_relay_test.go`
4. Keep mobile relay runtime changes tied to explicit proof in `pulse-mobile:src/relay/__tests__/`
4. Keep backend-owned mobile capability inventory changes tied to explicit proof in `internal/api/relay_mobile_capability_test.go`
5. Keep mobile relay runtime changes tied to explicit proof in `pulse-mobile:src/relay/__tests__/`
## Current State
@ -97,3 +101,8 @@ The mobile relay runtime is part of the same owned surface: reconnect drain
failover hints are one-shot recovery instructions, not permanent relay URL
overrides, so a successful failover reconnect must return future reconnects to
the instance's canonical relay URL unless the server sends a fresh drain hint.
The server-owned mobile relay capability boundary is part of that same owned
surface too. The dedicated `relay:mobile:access` credential may only reach the
explicit runtime route inventory in `internal/api/relay_mobile_capability.go`,
and expanding that inventory is governed L7 work rather than a router-local
compatibility tweak.

View file

@ -125,14 +125,6 @@ owner for privacy-document URLs, while `frontend-modern/public/docs/PRIVACY.md`
is the version-matched asset served by the running build. Privacy disclosures
must not drift back to GitHub `main` links that can describe a different
revision than the installed runtime.
That same shipped-doc boundary now also governs the rest of the operator
security trust surface: API token scope reference links, proxy-auth guidance,
and the runtime security warning must open the local
`/docs/CONFIGURATION.md`, `/docs/PROXY_AUTH.md`, and `/docs/SECURITY.md`
assets instead of version-drifting GitHub URLs, and
`frontend-modern/src/components/SecurityWarning.tsx` must stay reactive after
its async status fetch so low-security installs actually surface that governed
security guidance in the running UI.
That same governed settings trust boundary now also includes
`frontend-modern/src/components/Settings/QuickSecuritySetup.tsx`,
`frontend-modern/src/components/Settings/SecurityPostureSummary.tsx`,
@ -170,9 +162,11 @@ That same token-scope boundary now also governs Pulse Mobile relay runtime
credentials: `internal/api/security_tokens.go` must mint only the dedicated
backend-owned `relay:mobile:access` scope for new mobile relay tokens, and the
shared auth/router helpers may expose backward-compatible gates for older
mobile tokens only on the governed mobile runtime routes. Browser callers and
route-local handlers must not recreate wildcard or broad AI-scoped mobile
credentials.
mobile tokens only on the governed mobile runtime routes enumerated in
`internal/api/relay_mobile_capability.go`. Browser callers and route-local
handlers must not recreate wildcard or broad AI-scoped mobile credentials, and
future route expansion must update that backend-owned inventory explicitly
rather than widening compatibility through ad hoc handler checks.
That same trust rule also applies to AI-owned persisted state under
`internal/config/persistence.go`: findings, usage history, patrol run history,
and chat sessions may use plaintext files only as migration input. Once those

View file

@ -76,7 +76,7 @@ querying, and the operator-facing storage health presentation layer.
15. Preserve the governed root-or-sudo Unix wrapper in shared backend install-command helpers so storage- and recovery-adjacent transport surfaces do not inherit a stale raw `| bash -s --` install payload shape from the canonical agent-install-command API and hosted Proxmox install responses.
16. Preserve optional-auth tokenless behavior in those same shared backend install-command helpers so adjacent transport surfaces do not implicitly persist API tokens and flip auth-configured state when an operator only requested a Proxmox install command on a token-optional Pulse instance.
17. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so storage- and recovery-adjacent transport surfaces do not inherit browser-authored wildcard token bundles when they depend on the canonical security helper layer.
18. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route gates in those same shared helpers so storage- and recovery-adjacent transport surfaces do not treat the mobile relay credential as a general AI scope bundle.
18. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so storage- and recovery-adjacent transport surfaces do not treat the mobile relay credential as a general AI scope bundle.
## Forbidden Paths
@ -137,6 +137,10 @@ websocket upgrades trust the cloud proxy only through explicit tenant
`PULSE_TRUSTED_PROXY_CIDRS` wiring, so storage- and recovery-adjacent live
status surfaces do not fall into reconnect loops after a hosted workspace
handoff.
That same shared helper layer also now assumes the Pulse Mobile relay runtime
credential reaches only the explicit backend-owned route inventory, so
storage- and recovery-adjacent transport work cannot accidentally widen that
credential into a broader AI access bundle by touching neighboring routes.
The recovery frontend now also separates that ownership more explicitly:
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` owns
canonical route parsing, filter/query state, transport hook inputs, and URL

View file

@ -6379,7 +6379,7 @@ func (h *AISettingsHandler) HandleApproveCommand(w http.ResponseWriter, r *http.
// SECURITY: Approval execution accepts the dedicated mobile relay capability
// for new pairings while remaining backward-compatible with older mobile
// tokens that still carry ai:execute.
if !ensureAnyScope(w, r, relayMobileExecuteCompatibleScopes...) {
if !ensureRelayMobileRuntimeRoute(w, r, relayMobileRouteApprovalApprove) {
return
}
@ -6488,6 +6488,9 @@ func (h *AISettingsHandler) HandleDenyCommand(w http.ResponseWriter, r *http.Req
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !ensureRelayMobileRuntimeRoute(w, r, relayMobileRouteApprovalDeny) {
return
}
// Extract ID from path: /api/ai/approvals/{id}/deny
path := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/")

View file

@ -2005,6 +2005,46 @@ func TestAISettingsHandler_Approvals(t *testing.T) {
assert.Equal(t, approval.StatusDenied, app.Status)
assert.Equal(t, "too dangerous", app.DenyReason)
})
t.Run("HandleApproveCommand_AcceptsRelayMobileScope", func(t *testing.T) {
appID3 := "app-789"
_ = approvalStore.CreateApproval(&approval.ApprovalRequest{
ID: appID3,
Command: "pwd",
Status: approval.StatusPending,
})
req := httptest.NewRequest(http.MethodPost, "/api/ai/approvals/"+appID3+"/approve", nil)
record := config.APITokenRecord{ID: "relay-mobile-approve", Scopes: []string{config.ScopeRelayMobileAccess}}
attachAPITokenRecord(req, &record)
rec := httptest.NewRecorder()
handler.HandleApproveCommand(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
app, _ := approvalStore.GetApproval(appID3)
assert.Equal(t, approval.StatusApproved, app.Status)
})
t.Run("HandleDenyCommand_AcceptsRelayMobileScope", func(t *testing.T) {
appID4 := "app-999"
_ = approvalStore.CreateApproval(&approval.ApprovalRequest{
ID: appID4,
Command: "rm -rf /tmp",
Status: approval.StatusPending,
})
body, _ := json.Marshal(map[string]string{"reason": "not now"})
req := httptest.NewRequest(http.MethodPost, "/api/ai/approvals/"+appID4+"/deny", bytes.NewReader(body))
record := config.APITokenRecord{ID: "relay-mobile-deny", Scopes: []string{config.ScopeRelayMobileAccess}}
attachAPITokenRecord(req, &record)
rec := httptest.NewRecorder()
handler.HandleDenyCommand(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
app, _ := approvalStore.GetApproval(appID4)
assert.Equal(t, approval.StatusDenied, app.Status)
assert.Equal(t, "not now", app.DenyReason)
})
}
func TestAISettingsHandler_Approvals_RejectCrossOrgAccess(t *testing.T) {

View file

@ -1634,6 +1634,25 @@ func TestContract_RelayMobileScopeCanReadOnboardingDeepLink(t *testing.T) {
}
}
func TestContract_RelayMobileScopeCannotReadApprovalDetail(t *testing.T) {
rawToken := "relay-mobile-approval-detail-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals/approval-1", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("relay mobile scope should not satisfy approval detail gating, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
t.Fatalf("relay mobile approval detail rejection should mention %q, got %s", config.ScopeAIExecute, rec.Body.String())
}
}
func TestContract_UnifiedAgentReportResponseJSONSnapshot(t *testing.T) {
payload := map[string]any{
"success": true,

View file

@ -0,0 +1,198 @@
package api
import (
"fmt"
"net/http"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
type relayMobileRuntimeRouteID string
type relayMobileRuntimeRouteSpec struct {
id relayMobileRuntimeRouteID
method string
path string
requiredScope string
}
const (
relayMobileRouteOnboardingQR relayMobileRuntimeRouteID = "onboarding-qr"
relayMobileRouteOnboardingValidate relayMobileRuntimeRouteID = "onboarding-validate"
relayMobileRouteOnboardingDeepLink relayMobileRuntimeRouteID = "onboarding-deep-link"
relayMobileRoutePatrolFindingsList relayMobileRuntimeRouteID = "patrol-findings-list"
relayMobileRouteFindingInvestigation relayMobileRuntimeRouteID = "finding-investigation"
relayMobileRouteFindingInvestigationMessages relayMobileRuntimeRouteID = "finding-investigation-messages"
relayMobileRoutePatrolAcknowledge relayMobileRuntimeRouteID = "patrol-acknowledge"
relayMobileRoutePatrolDismiss relayMobileRuntimeRouteID = "patrol-dismiss"
relayMobileRoutePatrolSnooze relayMobileRuntimeRouteID = "patrol-snooze"
relayMobileRouteApprovalsList relayMobileRuntimeRouteID = "approvals-list"
relayMobileRouteApprovalApprove relayMobileRuntimeRouteID = "approval-approve"
relayMobileRouteApprovalDeny relayMobileRuntimeRouteID = "approval-deny"
relayMobileRouteChatSend relayMobileRuntimeRouteID = "chat-send"
relayMobileRouteSessionsList relayMobileRuntimeRouteID = "sessions-list"
relayMobileRouteSessionCreate relayMobileRuntimeRouteID = "session-create"
relayMobileRouteSessionMessages relayMobileRuntimeRouteID = "session-messages"
relayMobileRouteSessionAbort relayMobileRuntimeRouteID = "session-abort"
relayMobileRouteSessionDelete relayMobileRuntimeRouteID = "session-delete"
)
var relayMobileRuntimeRouteOrder = []relayMobileRuntimeRouteID{
relayMobileRouteOnboardingQR,
relayMobileRouteOnboardingValidate,
relayMobileRouteOnboardingDeepLink,
relayMobileRoutePatrolFindingsList,
relayMobileRouteFindingInvestigation,
relayMobileRouteFindingInvestigationMessages,
relayMobileRoutePatrolAcknowledge,
relayMobileRoutePatrolDismiss,
relayMobileRoutePatrolSnooze,
relayMobileRouteApprovalsList,
relayMobileRouteApprovalApprove,
relayMobileRouteApprovalDeny,
relayMobileRouteChatSend,
relayMobileRouteSessionsList,
relayMobileRouteSessionCreate,
relayMobileRouteSessionMessages,
relayMobileRouteSessionAbort,
relayMobileRouteSessionDelete,
}
var relayMobileRuntimeRouteSpecs = map[relayMobileRuntimeRouteID]relayMobileRuntimeRouteSpec{
relayMobileRouteOnboardingQR: {
id: relayMobileRouteOnboardingQR,
method: http.MethodGet,
path: "/api/onboarding/qr",
requiredScope: config.ScopeSettingsRead,
},
relayMobileRouteOnboardingValidate: {
id: relayMobileRouteOnboardingValidate,
method: http.MethodPost,
path: "/api/onboarding/validate",
requiredScope: config.ScopeSettingsRead,
},
relayMobileRouteOnboardingDeepLink: {
id: relayMobileRouteOnboardingDeepLink,
method: http.MethodGet,
path: "/api/onboarding/deep-link",
requiredScope: config.ScopeSettingsRead,
},
relayMobileRoutePatrolFindingsList: {
id: relayMobileRoutePatrolFindingsList,
method: http.MethodGet,
path: "/api/ai/patrol/findings",
requiredScope: config.ScopeAIExecute,
},
relayMobileRouteFindingInvestigation: {
id: relayMobileRouteFindingInvestigation,
method: http.MethodGet,
path: "/api/ai/findings/{finding_id}/investigation",
requiredScope: config.ScopeAIExecute,
},
relayMobileRouteFindingInvestigationMessages: {
id: relayMobileRouteFindingInvestigationMessages,
method: http.MethodGet,
path: "/api/ai/findings/{finding_id}/investigation/messages",
requiredScope: config.ScopeAIExecute,
},
relayMobileRoutePatrolAcknowledge: {
id: relayMobileRoutePatrolAcknowledge,
method: http.MethodPost,
path: "/api/ai/patrol/acknowledge",
requiredScope: config.ScopeAIExecute,
},
relayMobileRoutePatrolDismiss: {
id: relayMobileRoutePatrolDismiss,
method: http.MethodPost,
path: "/api/ai/patrol/dismiss",
requiredScope: config.ScopeAIExecute,
},
relayMobileRoutePatrolSnooze: {
id: relayMobileRoutePatrolSnooze,
method: http.MethodPost,
path: "/api/ai/patrol/snooze",
requiredScope: config.ScopeAIExecute,
},
relayMobileRouteApprovalsList: {
id: relayMobileRouteApprovalsList,
method: http.MethodGet,
path: "/api/ai/approvals",
requiredScope: config.ScopeAIExecute,
},
relayMobileRouteApprovalApprove: {
id: relayMobileRouteApprovalApprove,
method: http.MethodPost,
path: "/api/ai/approvals/{approval_id}/approve",
requiredScope: config.ScopeAIExecute,
},
relayMobileRouteApprovalDeny: {
id: relayMobileRouteApprovalDeny,
method: http.MethodPost,
path: "/api/ai/approvals/{approval_id}/deny",
requiredScope: config.ScopeAIExecute,
},
relayMobileRouteChatSend: {
id: relayMobileRouteChatSend,
method: http.MethodPost,
path: "/api/ai/chat",
requiredScope: config.ScopeAIChat,
},
relayMobileRouteSessionsList: {
id: relayMobileRouteSessionsList,
method: http.MethodGet,
path: "/api/ai/sessions",
requiredScope: config.ScopeAIChat,
},
relayMobileRouteSessionCreate: {
id: relayMobileRouteSessionCreate,
method: http.MethodPost,
path: "/api/ai/sessions",
requiredScope: config.ScopeAIChat,
},
relayMobileRouteSessionMessages: {
id: relayMobileRouteSessionMessages,
method: http.MethodGet,
path: "/api/ai/sessions/{session_id}/messages",
requiredScope: config.ScopeAIChat,
},
relayMobileRouteSessionAbort: {
id: relayMobileRouteSessionAbort,
method: http.MethodPost,
path: "/api/ai/sessions/{session_id}/abort",
requiredScope: config.ScopeAIChat,
},
relayMobileRouteSessionDelete: {
id: relayMobileRouteSessionDelete,
method: http.MethodDelete,
path: "/api/ai/sessions/{session_id}",
requiredScope: config.ScopeAIChat,
},
}
func relayMobileRuntimeRouteInventory() []relayMobileRuntimeRouteSpec {
inventory := make([]relayMobileRuntimeRouteSpec, 0, len(relayMobileRuntimeRouteOrder))
for _, routeID := range relayMobileRuntimeRouteOrder {
inventory = append(inventory, relayMobileRuntimeRouteSpecFor(routeID))
}
return inventory
}
func relayMobileRuntimeRouteSpecFor(routeID relayMobileRuntimeRouteID) relayMobileRuntimeRouteSpec {
spec, ok := relayMobileRuntimeRouteSpecs[routeID]
if !ok {
panic(fmt.Sprintf("unknown relay mobile runtime route %q", routeID))
}
return spec
}
func (spec relayMobileRuntimeRouteSpec) compatibleScopes() []string {
return []string{config.ScopeRelayMobileAccess, spec.requiredScope}
}
func requireRelayMobileRuntimeRoute(routeID relayMobileRuntimeRouteID, handler http.HandlerFunc) http.HandlerFunc {
return RequireAnyScope(relayMobileRuntimeRouteSpecFor(routeID).compatibleScopes(), handler)
}
func ensureRelayMobileRuntimeRoute(w http.ResponseWriter, r *http.Request, routeID relayMobileRuntimeRouteID) bool {
return ensureAnyScope(w, r, relayMobileRuntimeRouteSpecFor(routeID).compatibleScopes()...)
}

View file

@ -0,0 +1,65 @@
package api
import (
"fmt"
"reflect"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestRelayMobileRuntimeRouteInventory(t *testing.T) {
got := make([]string, 0, len(relayMobileRuntimeRouteOrder))
seenIDs := make(map[relayMobileRuntimeRouteID]struct{}, len(relayMobileRuntimeRouteOrder))
seenRoutes := make(map[string]struct{}, len(relayMobileRuntimeRouteOrder))
for _, spec := range relayMobileRuntimeRouteInventory() {
if _, exists := seenIDs[spec.id]; exists {
t.Fatalf("duplicate relay mobile runtime route id %q", spec.id)
}
seenIDs[spec.id] = struct{}{}
scopes := spec.compatibleScopes()
if len(scopes) != 2 {
t.Fatalf("compatible scopes for %q = %d, want 2", spec.id, len(scopes))
}
if scopes[0] != config.ScopeRelayMobileAccess {
t.Fatalf("compatible scopes for %q start with %q, want %q", spec.id, scopes[0], config.ScopeRelayMobileAccess)
}
if scopes[1] != spec.requiredScope {
t.Fatalf("compatible scopes for %q end with %q, want %q", spec.id, scopes[1], spec.requiredScope)
}
route := fmt.Sprintf("%s %s => %s", spec.method, spec.path, spec.requiredScope)
if _, exists := seenRoutes[route]; exists {
t.Fatalf("duplicate relay mobile runtime route %q", route)
}
seenRoutes[route] = struct{}{}
got = append(got, route)
}
want := []string{
"GET /api/onboarding/qr => settings:read",
"POST /api/onboarding/validate => settings:read",
"GET /api/onboarding/deep-link => settings:read",
"GET /api/ai/patrol/findings => ai:execute",
"GET /api/ai/findings/{finding_id}/investigation => ai:execute",
"GET /api/ai/findings/{finding_id}/investigation/messages => ai:execute",
"POST /api/ai/patrol/acknowledge => ai:execute",
"POST /api/ai/patrol/dismiss => ai:execute",
"POST /api/ai/patrol/snooze => ai:execute",
"GET /api/ai/approvals => ai:execute",
"POST /api/ai/approvals/{approval_id}/approve => ai:execute",
"POST /api/ai/approvals/{approval_id}/deny => ai:execute",
"POST /api/ai/chat => ai:chat",
"GET /api/ai/sessions => ai:chat",
"POST /api/ai/sessions => ai:chat",
"GET /api/ai/sessions/{session_id}/messages => ai:chat",
"POST /api/ai/sessions/{session_id}/abort => ai:chat",
"DELETE /api/ai/sessions/{session_id} => ai:chat",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("relay mobile runtime route inventory = %#v, want %#v", got, want)
}
}

View file

@ -660,12 +660,12 @@ func (r *Router) routeAISessions(w http.ResponseWriter, req *http.Request) {
if len(parts) > 1 {
switch parts[1] {
case "messages":
if !ensureAnyScope(w, req, relayMobileChatCompatibleScopes...) {
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionMessages) {
return
}
r.aiHandler.HandleMessages(w, req, sessionID)
case "abort":
if !ensureAnyScope(w, req, relayMobileChatCompatibleScopes...) {
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionAbort) {
return
}
r.aiHandler.HandleAbort(w, req, sessionID)
@ -706,7 +706,7 @@ func (r *Router) routeAISessions(w http.ResponseWriter, req *http.Request) {
// Handle session-level operations
switch req.Method {
case http.MethodDelete:
if !ensureAnyScope(w, req, relayMobileChatCompatibleScopes...) {
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionDelete) {
return
}
r.aiHandler.HandleDeleteSession(w, req, sessionID)
@ -719,16 +719,21 @@ func (r *Router) routeAISessions(w http.ResponseWriter, req *http.Request) {
}
func (r *Router) routeAISessionsCollection(w http.ResponseWriter, req *http.Request) {
if !ensureAnyScope(w, req, relayMobileChatCompatibleScopes...) {
return
}
switch req.Method {
case http.MethodGet:
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionsList) {
return
}
r.aiHandler.HandleSessions(w, req)
case http.MethodPost:
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionCreate) {
return
}
r.aiHandler.HandleCreateSession(w, req)
default:
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
@ -736,7 +741,7 @@ func (r *Router) routeAISessionsCollection(w http.ResponseWriter, req *http.Requ
func (r *Router) routeAIPatrolFindings(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureAnyScope(w, req, relayMobileExecuteCompatibleScopes...) {
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRoutePatrolFindingsList) {
return
}
r.aiSettingsHandler.HandleGetPatrolFindings(w, req)
@ -758,12 +763,12 @@ func (r *Router) routeAIFindings(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
switch {
case strings.HasSuffix(path, "/investigation/messages"):
if !ensureAnyScope(w, req, relayMobileExecuteCompatibleScopes...) {
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteFindingInvestigationMessages) {
return
}
r.aiSettingsHandler.HandleGetInvestigationMessages(w, req)
case strings.HasSuffix(path, "/investigation"):
if !ensureAnyScope(w, req, relayMobileExecuteCompatibleScopes...) {
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteFindingInvestigation) {
return
}
r.aiSettingsHandler.HandleGetInvestigation(w, req)
@ -800,10 +805,19 @@ func (r *Router) routeApprovals(w http.ResponseWriter, req *http.Request) {
if len(parts) > 1 {
switch parts[1] {
case "approve":
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteApprovalApprove) {
return
}
r.aiSettingsHandler.HandleApproveCommand(w, req)
case "deny":
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteApprovalDeny) {
return
}
r.aiSettingsHandler.HandleDenyCommand(w, req)
default:
if !ensureScope(w, req, config.ScopeAIExecute) {
return
}
http.Error(w, "Not found", http.StatusNotFound)
}
return
@ -812,8 +826,14 @@ func (r *Router) routeApprovals(w http.ResponseWriter, req *http.Request) {
// Handle approval-level operations (GET specific approval)
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeAIExecute) {
return
}
r.aiSettingsHandler.HandleGetApproval(w, req)
default:
if !ensureScope(w, req, config.ScopeAIExecute) {
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

View file

@ -25,12 +25,6 @@ const (
featureAIAutoFixKey = "ai_autofix"
)
var (
relayMobileChatCompatibleScopes = []string{config.ScopeRelayMobileAccess, config.ScopeAIChat}
relayMobileExecuteCompatibleScopes = []string{config.ScopeRelayMobileAccess, config.ScopeAIExecute}
relayMobileOnboardingScopes = []string{config.ScopeRelayMobileAccess, config.ScopeSettingsRead}
)
func (r *Router) registerAIRelayRoutesGroup() {
// Resolve AI extension endpoints. In the open-source build (no enterprise binder),
// the free adapters return 402 for all premium operations. Enterprise binders
@ -86,19 +80,19 @@ func (r *Router) registerAIRelayRoutesGroup() {
r.mux.HandleFunc("GET /api/settings/relay", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, RequireLicenseFeature(r.licenseHandlers, featureRelayKey, r.handleGetRelayConfig))))
r.mux.HandleFunc("PUT /api/settings/relay", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, RequireLicenseFeature(r.licenseHandlers, featureRelayKey, r.handleUpdateRelayConfig))))
r.mux.HandleFunc("GET /api/settings/relay/status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, RequireLicenseFeature(r.licenseHandlers, featureRelayKey, r.handleGetRelayStatus))))
r.mux.HandleFunc("GET /api/onboarding/qr", RequireAuth(r.config, RequireAnyScope(relayMobileOnboardingScopes, func(w http.ResponseWriter, req *http.Request) {
r.mux.HandleFunc("GET /api/onboarding/qr", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteOnboardingQR, func(w http.ResponseWriter, req *http.Request) {
if getAPITokenRecordFromRequest(req) == nil && !ensureSettingsReadScope(r.config, w, req) {
return
}
RequireLicenseFeature(r.licenseHandlers, featureRelayKey, r.handleGetOnboardingQR)(w, req)
})))
r.mux.HandleFunc("POST /api/onboarding/validate", RequireAuth(r.config, RequireAnyScope(relayMobileOnboardingScopes, func(w http.ResponseWriter, req *http.Request) {
r.mux.HandleFunc("POST /api/onboarding/validate", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteOnboardingValidate, func(w http.ResponseWriter, req *http.Request) {
if getAPITokenRecordFromRequest(req) == nil && !ensureSettingsReadScope(r.config, w, req) {
return
}
RequireLicenseFeature(r.licenseHandlers, featureRelayKey, r.handleValidateOnboardingConnection)(w, req)
})))
r.mux.HandleFunc("GET /api/onboarding/deep-link", RequireAuth(r.config, RequireAnyScope(relayMobileOnboardingScopes, func(w http.ResponseWriter, req *http.Request) {
r.mux.HandleFunc("GET /api/onboarding/deep-link", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteOnboardingDeepLink, func(w http.ResponseWriter, req *http.Request) {
if getAPITokenRecordFromRequest(req) == nil && !ensureSettingsReadScope(r.config, w, req) {
return
}
@ -118,13 +112,13 @@ func (r *Router) registerAIRelayRoutesGroup() {
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleForcePatrol)))
// SECURITY: AI Patrol mutation endpoints - require ai:execute scope to prevent low-privilege tokens from
// dismissing, suppressing, or otherwise hiding findings. This prevents attackers from blinding AI Patrol.
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, RequireAnyScope(relayMobileExecuteCompatibleScopes, r.aiSettingsHandler.HandleAcknowledgeFinding)))
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRoutePatrolAcknowledge, r.aiSettingsHandler.HandleAcknowledgeFinding)))
// Dismiss and resolve don't require Pro license - users should be able to clear findings they can see
// This is especially important for users who accumulated findings before fixing the patrol-without-AI bug
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, RequireAnyScope(relayMobileExecuteCompatibleScopes, r.aiSettingsHandler.HandleDismissFinding)))
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRoutePatrolDismiss, r.aiSettingsHandler.HandleDismissFinding)))
r.mux.HandleFunc("/api/ai/patrol/findings/note", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSetFindingNote)))
r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSuppressFinding)))
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, RequireAnyScope(relayMobileExecuteCompatibleScopes, r.aiSettingsHandler.HandleSnoozeFinding)))
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRoutePatrolSnooze, r.aiSettingsHandler.HandleSnoozeFinding)))
r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleResolveFinding)))
r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolRunHistory)))
r.mux.HandleFunc("/api/ai/patrol/runs/", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolRun)))
@ -203,14 +197,14 @@ func (r *Router) registerAIRelayRoutesGroup() {
// AI chat endpoints
// SECURITY: Status endpoint is part of chat UX and should require ai:chat scope for token clients.
r.mux.HandleFunc("/api/ai/status", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.aiHandler.HandleStatus)))
r.mux.HandleFunc("/api/ai/chat", RequireAuth(r.config, RequireAnyScope(relayMobileChatCompatibleScopes, r.aiHandler.HandleChat)))
r.mux.HandleFunc("/api/ai/chat", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteChatSend, r.aiHandler.HandleChat)))
r.mux.HandleFunc("/api/ai/sessions", RequireAuth(r.config, r.routeAISessionsCollection))
r.mux.HandleFunc("/api/ai/sessions/", RequireAuth(r.config, r.routeAISessions))
// AI approval endpoints - for command approval workflow
// Require ai:execute scope to prevent low-privilege tokens from enumerating or denying approvals
r.mux.HandleFunc("/api/ai/approvals", RequireAuth(r.config, RequireAnyScope(relayMobileExecuteCompatibleScopes, r.aiAutoFixEndpoints.HandleListApprovals)))
r.mux.HandleFunc("/api/ai/approvals/", RequireAuth(r.config, RequireAnyScope(relayMobileExecuteCompatibleScopes, r.routeApprovals)))
r.mux.HandleFunc("/api/ai/approvals", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteApprovalsList, r.aiAutoFixEndpoints.HandleListApprovals)))
r.mux.HandleFunc("/api/ai/approvals/", RequireAuth(r.config, r.routeApprovals))
// AI question endpoints - require ai:chat scope for interactive AI features
r.mux.HandleFunc("/api/ai/question/", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.routeQuestions)))
@ -384,11 +378,13 @@ func newAIAutoFixHandlerDeps(r *Router) extensions.AIAutoFixHandlerDeps {
return getAuthUsername(h.getConfig(req.Context()), req)
},
EnsureScope: func(w http.ResponseWriter, req *http.Request, scope string) bool {
if scope == config.ScopeAIExecute && strings.HasPrefix(req.URL.Path, "/api/ai/approvals/") {
if scope == config.ScopeAIExecute &&
strings.HasPrefix(req.URL.Path, "/api/ai/approvals/") &&
strings.HasSuffix(req.URL.Path, "/approve") {
// Approval execution accepts the dedicated mobile relay capability
// for new pairings while remaining backward-compatible with legacy
// ai:execute-scoped mobile tokens.
return ensureAnyScope(w, req, relayMobileExecuteCompatibleScopes...)
return ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteApprovalApprove)
}
return ensureScope(w, req, scope)
},

View file

@ -1347,9 +1347,11 @@ func TestRelayMobileAccessScopeDeniesAdjacentAIRoutes(t *testing.T) {
{method: http.MethodGet, path: "/api/ai/models", body: "", want: config.ScopeAIChat},
{method: http.MethodGet, path: "/api/ai/status", body: "", want: config.ScopeAIChat},
{method: http.MethodGet, path: "/api/ai/agents", body: "", want: config.ScopeAIExecute},
{method: http.MethodGet, path: "/api/ai/approvals/approval-1", body: "", want: config.ScopeAIExecute},
{method: http.MethodDelete, path: "/api/ai/patrol/findings", body: "", want: config.ScopeAIExecute},
{method: http.MethodPost, path: "/api/ai/findings/finding-1/reinvestigate", body: `{}`, want: config.ScopeAIExecute},
{method: http.MethodPost, path: "/api/ai/findings/finding-1/reapprove", body: `{}`, want: config.ScopeAIExecute},
{method: http.MethodGet, path: "/api/ai/sessions/session-1", body: "", want: config.ScopeAIChat},
{method: http.MethodPost, path: "/api/ai/sessions/session-1/summarize", body: `{}`, want: config.ScopeAIChat},
{method: http.MethodGet, path: "/api/ai/sessions/session-1/diff", body: "", want: config.ScopeAIChat},
{method: http.MethodPost, path: "/api/ai/sessions/session-1/fork", body: `{}`, want: config.ScopeAIChat},