Skip to content

Commit 889fa20

Browse files
feat(agents): share chats with permissions (#4505)
## Motivation Entity permission enforcement is now in place, and the users table is populated for tenant-scoped identity lookup. This PR exposes that capability in the agents UI so a chat manager can grant access to specific users or all users in the workspace without manually working with principal URLs or grant APIs. It also tightens the client-side auth and affordances around those permissions so non-managers do not see sharing controls, non-writers cannot send or signal, and shared sessions are recognizable in the sidebar. ## Changes - Adds a server-defined, tenant-scoped `users` Electric shape and a `usersCollection` in the agents UI provider for names, emails, and avatars. - Limits synced users columns to display-safe fields: id, display name, email, avatar URL, and timestamps. - Adds an `entity_effective_permissions` Electric shape and UI hooks for checking current-principal permissions against a chat. - Adds a chat sharing dialog that can grant roles to a selected user principal or to all workspace users via `subject_kind: principal_kind` and `subject_value: user`. - Maps sharing roles onto backend permissions explicitly: `View` grants `read` and `fork`; `Chat` grants `read`, `write`, `signal`, `fork`, `schedule`, and `spawn`; `Manage` grants `manage` and `delete`. - Resolves user principals consistently from raw `user:<id>` keys and `/principal/...` URLs so the current user is filtered from individual sharing, message metadata renders `Me`, and creator checks do not depend on principal encoding. - Displays synced user names for user principals in message metadata, share rows, sidebar creator filters, and sidebar hover cards when available. - Adds a small `Users` shared marker to sidebar rows for sessions created by another principal, plus a `Created by` sidebar filter with `Me` pinned first. - Gates UI actions using effective permissions: share is only visible with manage access; fork, send, stop/signal, pending-message controls, and signal menu actions are disabled when the current principal lacks the corresponding permission. - Gives forked chats `created_by` for the principal that clicked fork, so the new fork is controlled by its creator rather than the original chat owner. - Shows the signed-in Cloud user's principal key under Account settings for debugging. - Injects the signed-in Cloud user as the Electric principal for Cloud-targeted desktop requests, removes the legacy `x-electric-asserted-user-id` header, removes saved Cloud servers on logout so re-login refreshes stale service tokens, and avoids logging injected auth headers. - Adds focused coverage for principal normalization, share role mappings, users/effective-permissions shapes, fork ownership, Cloud auth header injection, and desktop server fetch auth injection. - Adds a changeset for `@electric-ax/agents-desktop`, `@electric-ax/agents-server`, and `@electric-ax/agents-server-ui`. ## Decisions The sharing UI creates explicit grants for every permission implied by a role instead of relying on backend permission implication. That keeps product-level roles transparent: for example, workspace “view” sharing can grant both `read` and `fork` even though `read` itself does not imply `fork`. “All users” sharing uses the existing principal-kind permission mechanism rather than synthetic user rows. Workspaces are siloed, so `principal_kind:user` means every user principal in the current tenant, not cross-workspace sharing. Permission gates in the UI are affordance controls, not the source of truth. Server-side permission checks still decide whether grant, signal, fork, spawn, and mutation APIs are allowed. Cloud desktop auth headers are merged after saved server headers so the signed-in Cloud user wins over stale saved `Electric-Principal` values. The sidebar shared marker only appears when `created_by` is present and differs from the current normalized principal. Missing creator data is left unmarked rather than inferred. --------- Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com>
1 parent 9da7b8f commit 889fa20

60 files changed

Lines changed: 3036 additions & 190 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@electric-ax/agents-server": patch
3+
"@electric-ax/agents-server-ui": patch
4+
"@electric-ax/agents-desktop": patch
5+
"@electric-ax/agents-mobile": patch
6+
---
7+
8+
Expose tenant-scoped users as an Electric shape and add a chat sharing dialog that grants user principals or all workspace users view, chat, or manage permissions over an entity. View/chat sharing includes fork access, forked chats are owned by the principal that creates the fork, shared chats can be identified and filtered by creator in the sidebar, and Cloud requests now inject the signed-in user as the Electric principal.
9+
10+
Mobile now syncs the users and effective-permissions shapes, marks and filters shared chats by creator, disables native chat and signal controls when the current principal lacks permission, and shows the signed-in user principal on the Account screen for debugging.

packages/agents-desktop/src/app/controller.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,36 @@ export function createDesktopMainController(ctx: DesktopAppContext) {
243243
const forgetServer = (serverId: string): Promise<void> =>
244244
runtime.forgetServer(serverId)
245245

246+
let forgetCloudServersOnSignOutPromise: Promise<void> | null = null
247+
const forgetCloudServersOnSignOut = (): void => {
248+
const cloudServerIds = settings.servers
249+
.filter((server) => server.source === `electric-cloud`)
250+
.map((server) => server.id)
251+
if (cloudServerIds.length === 0 && !ctx.services.cloudAgentServers) return
252+
if (forgetCloudServersOnSignOutPromise) return
253+
254+
forgetCloudServersOnSignOutPromise = (async () => {
255+
for (const serverId of cloudServerIds) {
256+
await forgetServer(serverId)
257+
}
258+
await ctx.getCloudAgentServers().forgetAllAgentsTokens()
259+
if (cloudServerIds.length > 0) {
260+
console.info(
261+
`[agents-desktop] Removed ${cloudServerIds.length} saved Cloud server(s) after Cloud sign-out.`
262+
)
263+
}
264+
})()
265+
.catch((err) => {
266+
console.warn(
267+
`[agents-desktop] Failed to remove saved Cloud servers after sign-out:`,
268+
err
269+
)
270+
})
271+
.finally(() => {
272+
forgetCloudServersOnSignOutPromise = null
273+
})
274+
}
275+
246276
const setSelectedServerForWindow = (
247277
win: BrowserWindow | null,
248278
serverId: string | null
@@ -460,6 +490,9 @@ export function createDesktopMainController(ctx: DesktopAppContext) {
460490

461491
return {
462492
broadcastCloudAuthState(next: CloudAuthState): void {
493+
if (next.status === `signed-out`) {
494+
forgetCloudServersOnSignOut()
495+
}
463496
for (const win of windows) {
464497
if (!win.isDestroyed()) {
465498
win.webContents.send(`desktop:cloud-auth-state-changed`, next)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ELECTRIC_PRINCIPAL_HEADER, mergeHeaders } from '../shared/headers'
2+
import type { ServerConfig } from '../shared/types'
3+
import { findCloudServerForUrl, findSavedServerForUrl } from './server-matching'
4+
import type { CloudAuthState } from './cloud-auth'
5+
6+
export type CloudAuthHeaderInjectionDeps = {
7+
getServers: () => Array<ServerConfig>
8+
getAgentsToken: (tenantId: string) => string | null | undefined
9+
getCloudAuthState: () => CloudAuthState | null | undefined
10+
injectDevPrincipalHeaders: (server: ServerConfig) => ServerConfig
11+
}
12+
13+
export function buildSavedServerHeaders(
14+
deps: CloudAuthHeaderInjectionDeps,
15+
url: string
16+
): Record<string, string> | null {
17+
const server = findSavedServerForUrl(deps.getServers(), url)
18+
if (!server) return null
19+
return mergeHeaders(deps.injectDevPrincipalHeaders(server).headers) ?? null
20+
}
21+
22+
/**
23+
* Build the cloud-auth headers to inject on a request to `url`, or `null` if
24+
* the URL doesn't target a saved cloud agent server.
25+
*/
26+
export function buildCloudAuthHeaders(
27+
deps: CloudAuthHeaderInjectionDeps,
28+
url: string
29+
): Record<string, string> | null {
30+
const server = findCloudServerForUrl(deps.getServers(), url)
31+
if (!server || !server.tenantId) return null
32+
const token = deps.getAgentsToken(server.tenantId)
33+
if (!token) return null
34+
const cloudAuthState = deps.getCloudAuthState()
35+
if (cloudAuthState?.status !== `signed-in` || !cloudAuthState.userId) {
36+
return null
37+
}
38+
const headers: Record<string, string> = {
39+
Authorization: `Bearer ${token}`,
40+
[ELECTRIC_PRINCIPAL_HEADER]: `user:${cloudAuthState.userId}`,
41+
}
42+
if (cloudAuthState?.email) {
43+
headers[`x-electric-asserted-email`] = cloudAuthState.email
44+
}
45+
if (cloudAuthState?.name) {
46+
headers[`x-electric-asserted-name`] = cloudAuthState.name
47+
}
48+
return headers
49+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import assert from 'node:assert/strict'
2+
import { describe, it } from 'node:test'
3+
import { buildCloudAuthHeaders, buildSavedServerHeaders } from './auth-headers'
4+
import { mergeHeaders } from '../shared/headers'
5+
import type { CloudAuthHeaderInjectionDeps } from './auth-headers'
6+
7+
const cloudServer = {
8+
id: `cloud-server`,
9+
name: `Cloud`,
10+
url: `http://localhost:8006/t/svc-example/v1/`,
11+
source: `electric-cloud`,
12+
tenantId: `svc-example`,
13+
desiredState: `connected`,
14+
localRuntimeEnabled: false,
15+
} as const
16+
17+
function deps(
18+
overrides: Partial<CloudAuthHeaderInjectionDeps> = {}
19+
): CloudAuthHeaderInjectionDeps {
20+
return {
21+
getServers: () => [cloudServer],
22+
getAgentsToken: () => `agents-token`,
23+
getCloudAuthState: () => ({
24+
status: `signed-in`,
25+
email: `ilia@example.com`,
26+
name: `Ilia`,
27+
userId: `69691edf-b925-4745-9c34-d7082eeb93e9`,
28+
workspaces: [],
29+
error: null,
30+
}),
31+
injectDevPrincipalHeaders: (server) => server,
32+
...overrides,
33+
}
34+
}
35+
36+
describe(`cloud auth header injection`, () => {
37+
it(`injects the signed-in Cloud user as the Electric principal`, () => {
38+
const headers = buildCloudAuthHeaders(
39+
deps(),
40+
`http://localhost:8006/t/svc-example/v1/_electric/entities/horton/a`
41+
)
42+
43+
assert.deepEqual(headers, {
44+
Authorization: `Bearer agents-token`,
45+
'electric-principal': `user:69691edf-b925-4745-9c34-d7082eeb93e9`,
46+
'x-electric-asserted-email': `ilia@example.com`,
47+
'x-electric-asserted-name': `Ilia`,
48+
})
49+
})
50+
51+
it(`lets Cloud principal headers override stale saved server principals`, () => {
52+
const requestUrl = `http://localhost:8006/t/svc-example/v1/_electric/entities/horton/a/grants`
53+
const staleSavedHeaders = buildSavedServerHeaders(
54+
deps({
55+
injectDevPrincipalHeaders: (server) => ({
56+
...server,
57+
headers: {
58+
'electric-principal': `user:e5736358-3d50-44c2-ba5c-598fc2743297`,
59+
},
60+
}),
61+
}),
62+
requestUrl
63+
)
64+
const cloudHeaders = buildCloudAuthHeaders(deps(), requestUrl)
65+
const merged = mergeHeaders(
66+
staleSavedHeaders ?? undefined,
67+
cloudHeaders ?? undefined
68+
)
69+
70+
assert.equal(
71+
merged?.[`electric-principal`],
72+
`user:69691edf-b925-4745-9c34-d7082eeb93e9`
73+
)
74+
})
75+
76+
it(`does not inject a service bearer without a signed-in Cloud principal`, () => {
77+
const headers = buildCloudAuthHeaders(
78+
deps({
79+
getCloudAuthState: () => ({
80+
status: `signed-in`,
81+
email: `ilia@example.com`,
82+
name: `Ilia`,
83+
userId: null,
84+
workspaces: null,
85+
error: null,
86+
}),
87+
}),
88+
`http://localhost:8006/t/svc-example/v1/_electric/entities/horton/a`
89+
)
90+
91+
assert.equal(headers, null)
92+
})
93+
})

packages/agents-desktop/src/cloud/auth-injection.ts

Lines changed: 15 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import { session } from 'electron'
22
import * as undici from 'undici'
33
import type { Dispatcher } from 'undici'
4-
import type { CloudAuthState } from './cloud-auth'
54
import { mergeHeaders } from '../shared/headers'
6-
import type { ServerConfig } from '../shared/types'
7-
import { findCloudServerForUrl, findSavedServerForUrl } from './server-matching'
8-
9-
export type CloudAuthHeaderInjectionDeps = {
10-
getServers: () => Array<ServerConfig>
11-
getAgentsToken: (tenantId: string) => string | null | undefined
12-
getCloudAuthState: () => CloudAuthState | null | undefined
13-
injectDevPrincipalHeaders: (server: ServerConfig) => ServerConfig
14-
}
5+
import {
6+
buildCloudAuthHeaders,
7+
buildSavedServerHeaders,
8+
type CloudAuthHeaderInjectionDeps,
9+
} from './auth-headers'
10+
export type { CloudAuthHeaderInjectionDeps } from './auth-headers'
1511

1612
/**
1713
* Decorate outgoing requests bound for saved agent servers with configured
@@ -30,51 +26,15 @@ export function installCloudAuthHeaderInjection(
3026
callback({ requestHeaders: details.requestHeaders })
3127
return
3228
}
29+
const requestHeaders = { ...details.requestHeaders, ...extra }
3330
callback({
34-
requestHeaders: { ...details.requestHeaders, ...extra },
31+
requestHeaders,
3532
})
3633
})
3734

3835
installCloudAuthUndiciInterceptor(deps)
3936
}
4037

41-
export function buildSavedServerHeaders(
42-
deps: CloudAuthHeaderInjectionDeps,
43-
url: string
44-
): Record<string, string> | null {
45-
const server = findSavedServerForUrl(deps.getServers(), url)
46-
if (!server) return null
47-
return mergeHeaders(deps.injectDevPrincipalHeaders(server).headers) ?? null
48-
}
49-
50-
/**
51-
* Build the cloud-auth headers to inject on a request to `url`, or `null` if
52-
* the URL doesn't target a saved cloud agent server.
53-
*/
54-
export function buildCloudAuthHeaders(
55-
deps: CloudAuthHeaderInjectionDeps,
56-
url: string
57-
): Record<string, string> | null {
58-
const server = findCloudServerForUrl(deps.getServers(), url)
59-
if (!server || !server.tenantId) return null
60-
const token = deps.getAgentsToken(server.tenantId)
61-
if (!token) return null
62-
const headers: Record<string, string> = {
63-
Authorization: `Bearer ${token}`,
64-
}
65-
const cloudAuthState = deps.getCloudAuthState()
66-
if (cloudAuthState?.userId) {
67-
headers[`x-electric-asserted-user-id`] = cloudAuthState.userId
68-
}
69-
if (cloudAuthState?.email) {
70-
headers[`x-electric-asserted-email`] = cloudAuthState.email
71-
}
72-
if (cloudAuthState?.name) {
73-
headers[`x-electric-asserted-name`] = cloudAuthState.name
74-
}
75-
return headers
76-
}
77-
7838
function installCloudAuthUndiciInterceptor(
7939
deps: CloudAuthHeaderInjectionDeps
8040
): void {
@@ -83,16 +43,17 @@ function installCloudAuthUndiciInterceptor(
8343
(dispatch): Dispatcher[`dispatch`] =>
8444
(opts, handler) => {
8545
const fullUrl = composeRequestUrl(opts.origin, opts.path)
86-
const extra = fullUrl ? buildCloudAuthHeaders(deps, fullUrl) : null
87-
if (!extra) return dispatch(opts, handler)
46+
if (!fullUrl) return dispatch(opts, handler)
47+
const extra = buildCloudAuthHeaders(deps, fullUrl)
48+
if (!extra) {
49+
return dispatch(opts, handler)
50+
}
8851
const lowered: Record<string, string> = {}
8952
for (const [key, value] of Object.entries(extra)) {
9053
lowered[key.toLowerCase()] = value
9154
}
92-
return dispatch(
93-
{ ...opts, headers: mergeUndiciHeaders(opts.headers, lowered) },
94-
handler
95-
)
55+
const headers = mergeUndiciHeaders(opts.headers, lowered)
56+
return dispatch({ ...opts, headers }, handler)
9657
}
9758
)
9859
undici.setGlobalDispatcher(composed)

packages/agents-desktop/src/cloud/cloud-agent-servers.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ export class CloudAgentServers {
185185
await this.secretStore.delete(`${TOKEN_REF_PREFIX}${tenantId}`)
186186
}
187187

188+
/** Drop every cached + persisted agents token. Used when Cloud auth signs out. */
189+
async forgetAllAgentsTokens(): Promise<void> {
190+
this.agentsTokens.clear()
191+
await this.secretStore.deleteByPrefix(TOKEN_REF_PREFIX)
192+
}
193+
188194
getState(): CloudAgentServersState {
189195
return this.state
190196
}
@@ -253,9 +259,6 @@ export class CloudAgentServers {
253259
tenantId: string
254260
): Promise<{ url: string; tenantId: string }> {
255261
const url = cloudAgentServerUrl(tenantId)
256-
const cached = this.getAgentsToken(tenantId)
257-
if (cached) return { url, tenantId }
258-
259262
const token = await this.cloudAuth.getToken()
260263
if (!token) {
261264
throw new Error(`Not signed in to Electric Cloud`)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import assert from 'node:assert/strict'
2+
import { afterEach, describe, it, mock } from 'node:test'
3+
import { desktopServerFetch } from './server-fetch'
4+
import type { DesktopServerFetchDeps } from './server-fetch'
5+
6+
const cloudServer = {
7+
id: `cloud-server`,
8+
name: `Cloud`,
9+
url: `http://localhost:8006/t/svc-example/v1/`,
10+
source: `electric-cloud`,
11+
tenantId: `svc-example`,
12+
desiredState: `connected`,
13+
localRuntimeEnabled: false,
14+
} as const
15+
16+
function deps(): DesktopServerFetchDeps {
17+
return {
18+
getServers: () => [cloudServer],
19+
getAgentsToken: () => `agents-token`,
20+
getCloudAuthState: () => ({
21+
status: `signed-in`,
22+
email: `ilia@example.com`,
23+
name: `Ilia`,
24+
userId: `69691edf-b925-4745-9c34-d7082eeb93e9`,
25+
workspaces: [],
26+
error: null,
27+
}),
28+
injectDevPrincipalHeaders: (server) => ({
29+
...server,
30+
headers: {
31+
'electric-principal': `user:e5736358-3d50-44c2-ba5c-598fc2743297`,
32+
},
33+
}),
34+
}
35+
}
36+
37+
describe(`desktop server fetch`, () => {
38+
afterEach(() => {
39+
mock.restoreAll()
40+
})
41+
42+
it(`overrides stale principals with Cloud principals for local Cloud mutations`, async () => {
43+
const fetchMock = mock.method(globalThis, `fetch`, async () => {
44+
return new Response(null, { status: 204 })
45+
})
46+
47+
const response = await desktopServerFetch(deps(), {
48+
url: `http://localhost:8006/t/svc-example/v1/_electric/entities/horton/a/grants`,
49+
method: `POST`,
50+
headers: {
51+
'content-type': `application/json`,
52+
'electric-principal': `user:stale-request-principal`,
53+
},
54+
body: `{}`,
55+
})
56+
57+
assert.equal(response.status, 204)
58+
assert.equal(fetchMock.mock.callCount(), 1)
59+
const init = fetchMock.mock.calls[0]?.arguments[1] as
60+
| RequestInit
61+
| undefined
62+
const headers = new Headers(init?.headers)
63+
assert.equal(
64+
headers.get(`electric-principal`),
65+
`user:69691edf-b925-4745-9c34-d7082eeb93e9`
66+
)
67+
assert.equal(headers.has(`x-electric-asserted-user-id`), false)
68+
assert.equal(headers.get(`authorization`), `Bearer agents-token`)
69+
})
70+
})

0 commit comments

Comments
 (0)