Skip to content

Commit e3dec5a

Browse files
committed
feat(ui): populate agent env from connection envMappings on grant
When a user grants a connection (e.g. Google Drive) in the Configure Agent dialog, any envMappings OneCLI declared for that app get appended to the agent's editable env list. The user can then edit or remove the entries like any other custom env var. On ungrant, entries that are still untouched (name + value match the declared mapping) and not required by any other still-granted app are removed automatically. Requires OneCLI 0.0.20+ which adds `envMappings` as a first-class field on the app registry and returns it on `GET /api/connections` (kagenti/onecli#16). Why this over storing envs on the connection itself: envs belong with the agent (that's the K8s resource that consumes them), and users may reasonably want to tweak the env name or remove a mapping for a specific agent without losing the grant. Humr has zero provider knowledge — the env-injection contract lives in OneCLI next to the OAuth config for each app. - api-server-api: `AppConnectionView.envMappings?` for UI consumption - api-server: passes through OneCLI's joined envMappings verbatim on `connections.list`; tests use a fictional provider fixture so they don't couple to any specific provider - ui: `EditAgentSecretsDialog.toggleApp` appends new app's envMappings to the agent env list on first grant (dedupe by env name — user-set wins); on ungrant removes entries only if they match the declared mapping and no other still-granted app declares the same envName - ui: `ConnectorsView` displays env names on each connection row so users can see what a grant will contribute - deploy: bump OneCLI image to 0.0.20 - docs: google-workspace README describes the new grant-time flow Signed-off-by: Matous Havlena <havlenma@gmail.com>
1 parent 8d5ad76 commit e3dec5a

8 files changed

Lines changed: 108 additions & 21 deletions

File tree

deploy/helm/humr/values.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ postgres:
4040
# -- OneCLI (credential proxy — gateway + web run in a single container)
4141
onecli:
4242
# Single Docker image containing both Rust gateway and Node.js web app
43-
image: ghcr.io/kagenti/onecli:0.0.19
43+
image: ghcr.io/kagenti/onecli:0.0.20
4444
replicas: 1
4545

4646
# -- Override the external hostname for OneCLI (empty = onecli.{{ domain }}).

packages/agents/google-workspace/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ A Humr agent template with the [Google Workspace CLI (`gws`)](https://github.com
4242

4343
The agent authenticates to Google APIs through Humr's credential injection:
4444

45-
1. The agent template sets `GOOGLE_WORKSPACE_CLI_TOKEN=humr:sentinel` in the pod environment
45+
1. When you grant a `gmail` or `google-drive` connection in the agent's Configure dialog, Humr auto-populates `GOOGLE_WORKSPACE_CLI_TOKEN=humr:sentinel` into the agent's editable env list. You can edit or remove it like any custom env var.
4646
2. When `gws` makes a request to `*.googleapis.com`, it sends `Authorization: Bearer humr:sentinel`
4747
3. The request goes through OneCLI's MITM proxy (`HTTPS_PROXY`)
4848
4. OneCLI recognizes the sentinel and replaces it with the real Bearer token
4949
5. Google receives a valid access token
5050

51-
The agent never sees your real Google credentials.
51+
The agent never sees your real Google credentials. OneCLI's app registry declares which env var each provider needs; Humr reads it and populates the agent env on grant.
5252

5353
## Token Lifecycle
5454

packages/api-server-api/src/modules/connections/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { EnvMapping } from "../secrets/types.js";
2+
13
export type AppConnectionStatus =
24
| "connected"
35
| "expired"
@@ -12,6 +14,12 @@ export interface AppConnectionView {
1214
identity?: string;
1315
scopes?: string[];
1416
connectedAt?: string;
17+
/**
18+
* Pod envs contributed by this connection. Declared by OneCLI's app
19+
* registry (see the matching `AppDefinition.envMappings` field) and
20+
* returned verbatim on `GET /api/connections` — Humr never writes this.
21+
*/
22+
envMappings?: EnvMapping[];
1523
}
1624

1725
export interface AgentAppConnections {

packages/api-server/src/__tests__/unit/connections-service.test.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,40 +73,64 @@ describe("extractIdentity", () => {
7373
});
7474

7575
describe("ConnectionsService.list", () => {
76+
// Fixture uses a fictional provider — this service is provider-agnostic and
77+
// just passes OneCLI's joined fields through to the view shape.
7678
const conn: OnecliAppConnection = {
7779
id: "c-1",
78-
provider: "gmail",
79-
providerName: "Gmail",
80+
provider: "acme-app",
81+
providerName: "Acme App",
8082
label: "user@example.com",
8183
status: "connected",
82-
scopes: ["openid"],
84+
scopes: ["read"],
8385
metadata: { email: "user@example.com" },
8486
connectedAt: "2026-04-17T00:00:00Z",
87+
envMappings: [{ envName: "ACME_TOKEN", placeholder: "test-placeholder" }],
8588
};
8689

87-
it("uses OneCLI's providerName as the label, with identity as subtitle", async () => {
90+
it("uses OneCLI's providerName as the label, with identity as subtitle, and passes through envMappings", async () => {
8891
const svc = createConnectionsService({
8992
port: makePort({ listAppConnections: async () => [conn] }),
9093
});
9194
const rows = await svc.list();
9295
expect(rows).toEqual([
9396
{
9497
id: "c-1",
95-
provider: "gmail",
96-
label: "Gmail",
98+
provider: "acme-app",
99+
label: "Acme App",
97100
status: "connected",
98101
identity: "user@example.com",
99-
scopes: ["openid"],
102+
scopes: ["read"],
100103
connectedAt: "2026-04-17T00:00:00Z",
104+
envMappings: [{ envName: "ACME_TOKEN", placeholder: "test-placeholder" }],
101105
},
102106
]);
103107
});
104108

109+
it("omits envMappings when the OneCLI response has none (provider lacks declared envs)", async () => {
110+
const svc = createConnectionsService({
111+
port: makePort({
112+
listAppConnections: async () => [{ ...conn, envMappings: null }],
113+
}),
114+
});
115+
const rows = await svc.list();
116+
expect(rows[0]).not.toHaveProperty("envMappings");
117+
});
118+
119+
it("omits envMappings when the array is empty", async () => {
120+
const svc = createConnectionsService({
121+
port: makePort({
122+
listAppConnections: async () => [{ ...conn, envMappings: [] }],
123+
}),
124+
});
125+
const rows = await svc.list();
126+
expect(rows[0]).not.toHaveProperty("envMappings");
127+
});
128+
105129
it("falls back to OneCLI label then provider id when providerName is missing", async () => {
106130
const svc = createConnectionsService({
107131
port: makePort({
108132
listAppConnections: async () => [
109-
// older OneCLI (<0.0.14) — providerName absent entirely
133+
// older OneCLI — providerName absent entirely
110134
{ ...conn, providerName: undefined, label: "user@example.com" },
111135
// registry orphan — providerName explicitly null
112136
{ ...conn, id: "c-2", providerName: null, label: null },
@@ -117,8 +141,8 @@ describe("ConnectionsService.list", () => {
117141
});
118142
const rows = await svc.list();
119143
expect(rows[0].label).toBe("user@example.com");
120-
expect(rows[1].label).toBe("gmail");
121-
expect(rows[2].label).toBe("gmail");
144+
expect(rows[1].label).toBe("acme-app");
145+
expect(rows[2].label).toBe("acme-app");
122146
});
123147

124148
it("omits optional scopes and connectedAt when absent", async () => {

packages/api-server/src/modules/connections/infrastructure/onecli-connections-port.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import type { EnvMapping } from "api-server-api";
12
import type { OnecliClient } from "../../../onecli.js";
23

34
/**
4-
* Row shape of OneCLI's `GET /api/connections` (>= onecli 0.0.14). Optional
5-
* fields are not narrowed — the service layer normalizes. `providerName` is
6-
* the app's display name from the registry (e.g. "Google Drive"); `label` is
7-
* the user's *identity* (email/username from metadata).
5+
* Row shape of OneCLI's `GET /api/connections`. Optional fields are not
6+
* narrowed — the service layer normalizes. `providerName` and `envMappings`
7+
* are both joined server-side from OneCLI's app registry (display name +
8+
* pod-env contract); the consumer never writes them.
89
*/
910
export interface OnecliAppConnection {
1011
id: string;
@@ -15,6 +16,7 @@ export interface OnecliAppConnection {
1516
scopes?: string[] | null;
1617
connectedAt?: string | null;
1718
metadata?: Record<string, unknown> | null;
19+
envMappings?: EnvMapping[] | null;
1820
}
1921

2022
export interface OnecliAgent {

packages/api-server/src/modules/connections/services/connections-service.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ export function createConnectionsService(deps: {
4242
}): ConnectionsService {
4343
return {
4444
async list() {
45-
// OneCLI >= 0.0.14 returns `providerName` populated from the app
46-
// registry (e.g. "Google Drive"). `label` is the user's identity
47-
// (email/username) — correct as a subtitle, wrong as a title.
45+
// OneCLI returns `providerName` and `envMappings` joined from the app
46+
// registry — provider-specific knowledge lives there, not here.
4847
const raw = await deps.port.listAppConnections();
4948
return raw
5049
.filter((c) => typeof c.id === "string" && typeof c.provider === "string")
@@ -56,6 +55,9 @@ export function createConnectionsService(deps: {
5655
identity: extractIdentity(c.metadata) || c.label?.trim() || undefined,
5756
...(c.scopes ? { scopes: c.scopes } : {}),
5857
...(c.connectedAt ? { connectedAt: c.connectedAt } : {}),
58+
...(c.envMappings && c.envMappings.length > 0
59+
? { envMappings: c.envMappings }
60+
: {}),
5961
}));
6062
},
6163

packages/ui/src/dialogs/edit-agent-secrets-dialog.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,50 @@ export function EditAgentSecretsDialog({
9090
const toggleApp = (id: string) =>
9191
setAssignedAppIds((p) => {
9292
const n = new Set(p);
93-
n.has(id) ? n.delete(id) : n.add(id);
93+
const app = apps.find((a) => a.id === id);
94+
if (n.has(id)) {
95+
n.delete(id);
96+
// On ungrant, remove envs this app contributed *only if* they're still
97+
// untouched (name + value match the declared mapping) AND no other
98+
// still-granted app declares the same envName. Either condition failing
99+
// means we must keep the entry: edited values are user intent; shared
100+
// envs (e.g. Gmail + Drive both want GOOGLE_WORKSPACE_CLI_TOKEN) are
101+
// still needed by the remaining grant.
102+
const mappings = app?.envMappings ?? [];
103+
if (mappings.length > 0) {
104+
const stillNeededNames = new Set(
105+
apps
106+
.filter((a) => n.has(a.id))
107+
.flatMap((a) => a.envMappings ?? [])
108+
.map((m) => m.envName),
109+
);
110+
const removable = new Map(
111+
mappings
112+
.filter((m) => !stillNeededNames.has(m.envName))
113+
.map((m) => [m.envName, m.placeholder] as const),
114+
);
115+
if (removable.size > 0) {
116+
setEnvVars((prev) =>
117+
prev.filter(
118+
(e) => !(removable.has(e.name) && removable.get(e.name) === e.value),
119+
),
120+
);
121+
}
122+
}
123+
} else {
124+
n.add(id);
125+
// On first grant, copy the app's declared envMappings into the editable
126+
// env list so the user can see what's being injected and adjust if
127+
// needed. Skip entries whose name is already present (user-set wins).
128+
const existing = new Set(envVars.map((e) => e.name));
129+
const toAdd = (app?.envMappings ?? []).filter((m) => !existing.has(m.envName));
130+
if (toAdd.length > 0) {
131+
setEnvVars((prev) => [
132+
...prev,
133+
...toAdd.map((m) => ({ name: m.envName, value: m.placeholder })),
134+
]);
135+
}
136+
}
94137
return n;
95138
});
96139

packages/ui/src/views/connections-view.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,14 @@ export function ConnectionsView() {
223223
: c.connectedAt
224224
? `Connected ${new Date(c.connectedAt).toLocaleDateString()}`
225225
: c.provider}
226+
{c.envMappings && c.envMappings.length > 0 && (
227+
<>
228+
{" · "}
229+
<span className="text-accent">
230+
{c.envMappings.map((m) => m.envName).join(", ")}
231+
</span>
232+
</>
233+
)}
226234
</div>
227235
</div>
228236
<AppStatusPill status={c.status} size="md" />

0 commit comments

Comments
 (0)