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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2905,6 +2905,30 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.environment-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid color-mix(in srgb, var(--warn) 42%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--warn) 8%, var(--surface));
}
.environment-notice strong,
.environment-notice p {
display: block;
margin: 0;
}
.environment-notice p {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.environment-notice button {
flex: none;
}
.governance-overview {
margin: 0 0 22px;
padding: 18px 20px;
Expand Down Expand Up @@ -3987,6 +4011,13 @@ <h1>Governance</h1>
<span>Scope</span><strong id="governance-scope-label">Organization</strong>
</div>
</div>
<aside class="environment-notice hidden" id="environment-notice" role="status">
<div>
<strong id="environment-notice-title"></strong>
<p id="environment-notice-detail"></p>
</div>
<button type="button" id="environment-notice-open">Open environment</button>
</aside>
<section class="governance-overview" id="governance-overview" aria-labelledby="governance-overview-title">
<div class="governance-overview-head">
<h2 id="governance-overview-title">Effective state</h2>
Expand Down Expand Up @@ -5717,6 +5748,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
});

let scopeDir = null;
let environmentDir = [];
let scopeDirNote = "Loading scopes…";
async function loadScopeDirectory() {
const r = await api("GET", "/api/scopes");
Expand All @@ -5728,6 +5760,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
}
viewLoadedAt.history = Date.now();
scopeDir = r.data.scopes || [];
environmentDir = r.data.environments || [];

if (SCOPED.has(view) && !urlToState().session) {
const memoryEditor = view === "memory" && !(orgWideView() && urlToState().mem !== "edit");
Expand Down Expand Up @@ -6244,6 +6277,19 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);
}
window.addEventListener("scroll", syncGovernanceSectionNav, { passive: true });
function renderEnvironmentNotice(data) {
const notice = $("environment-notice");
const attachment = data?.environmentAttachment;
notice.classList.toggle("hidden", !attachment);
if (!attachment) return;
const name = attachment.environmentName || shortName(attachment.environmentId);
$("environment-notice-title").textContent = "Uses named environment " + name;
$("environment-notice-detail").textContent =
"Computer files and working memory resolve to this environment. Governance and conversation history remain scoped here.";
$("environment-notice-open").textContent = "Open " + name;
$("environment-notice-open").onclick = () =>
go({ view: "governance", scope: attachment.environmentId, session: null, page: 1 });
}
let governanceReq = 0;
async function loadScope() {
const requestedScope = scope;
Expand All @@ -6259,6 +6305,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);
return;
}
renderEnvironmentNotice(r.data);
renderGovernanceOverview(r.data);
syncGovernanceSectionNav();
loadedCommandPolicyPresent = r.data.commandPolicy != null;
Expand Down Expand Up @@ -11451,6 +11498,21 @@ <h2 id="governance-review-title">Confirm governance change</h2>
actions: [sortControl],
});
const activityTime = (s) => (scopeSort === "human" ? s.lastConversationActivity || 0 : s.lastActivity || 0);
if (environmentDir.length) {
const environments = denseList(
environmentDir,
(environment) => ({
name: environment.name || shortName(environment.id),
preview: plural(environment.attachedScopes?.length || 0, "attached scope"),
href: stateToUrl({ view: "history", scope: environment.id, historyKind }),
}),
(environment) => selectScope(environment.id),
"No named environments.",
);
root.appendChild(
dataCard("Named environments", "Named computers and working memory that scopes can share.", environments),
);
}
const t = denseList(
activeRows,
(s) => {
Expand Down
13 changes: 13 additions & 0 deletions plugins/admin/test/environments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const html = readFileSync(join(import.meta.dirname, "../public/index.html"), "utf8");

test("the admin UI lists named environments and links attachment warnings", () => {
assert.match(html, /Named environments/);
assert.match(html, /id="environment-notice"/);
assert.match(html, /Uses named environment/);
assert.match(html, /scope: attachment\.environmentId/);
});
32 changes: 18 additions & 14 deletions skills-seed/google-workspace/scripts/gmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import formataddr, getaddresses
Expand All @@ -42,19 +41,24 @@ def call(method: str, path: str, body: dict | None = None, query: dict | None =
tok = os.environ.get("VAULT_TOKEN_GMAIL_GOOGLEAPIS_COM", "")
if not tok:
sys.exit("no Gmail token: ask the user to connect Google")
req = urllib.request.Request(
url,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"},
method=method,
)
# curl, not urllib: the sandbox egress proxy is an https:// CONNECT proxy,
# which python's urllib cannot tunnel through.
cmd = ["curl", "-sS", "--max-time", "60", "-X", method,
"-H", f"Authorization: Bearer {tok}", "-H", "Content-Type: application/json",
"-w", "\n%{http_code}", url]
if body is not None:
cmd[1:1] = ["--data-binary", "@-"]
res = subprocess.run(cmd, input=json.dumps(body) if body is not None else None,
capture_output=True, text=True)
if res.returncode != 0:
sys.exit(f"gmail api unreachable on {method} {path}: {res.stderr.strip()[:500]}")
text, _, status = res.stdout.rpartition("\n")
if not status.startswith("2"):
sys.exit(f"gmail api {status} on {method} {path}: {text[:500]}")
try:
with urllib.request.urlopen(req, timeout=60) as res:
return json.load(res)
except urllib.error.HTTPError as e:
sys.exit(f"gmail api {e.code} on {method} {path}: {e.read().decode()[:500]}")
except urllib.error.URLError as e:
sys.exit(f"gmail api unreachable on {method} {path}: {e.reason}")
return json.loads(text)
except ValueError:
sys.exit(f"gmail api {status} on {method} {path}: non-json response: {text[:500]}")


def read_body(path: str) -> str:
Expand Down
84 changes: 71 additions & 13 deletions src/api/routes/admin/scope-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,25 @@ export async function listAdminScopes(ctx: ApiCtx): Promise<void> {
const crons = await app.listCrons();
const deployments = await app.listDeployments();
const skills = await app.listSkills();
const environmentRows = await app.listEnvironments();
const environments = environmentRows.map(({ environment, attachments }) => ({
id: environment.id,
name: environment.name,
ownerActorId: environment.ownerActorId,
attachedScopes: attachments.map((attachment) => attachment.scopeId).sort(),
}));
const environmentById = new Map(environments.map((environment) => [environment.id, environment]));
const attachmentByScope = new Map(
environments.flatMap((environment) =>
environment.attachedScopes.map((attachedScope) => [attachedScope, environment] as const),
),
);
const owners = [
...crons.map((c) => c.ownerScopeId),
...deployments.map((d) => d.ownerScopeId),
...skills.map((s) => s.scopeId),
...environments.map((environment) => environment.id),
...environments.flatMap((environment) => environment.attachedScopes),
];
const labels = await discoverScopes(app, deps, owners);
const countBy = (ids: string[]): Map<string, number> => {
Expand Down Expand Up @@ -171,26 +186,67 @@ export async function listAdminScopes(ctx: ApiCtx): Promise<void> {
const cronN = countBy(crons.map((c) => c.ownerScopeId));
const deployN = countBy(deployments.map((d) => d.ownerScopeId));
const skillN = countBy(skills.map((s) => s.scopeId));
const scopes = [...labels].map(([id, label]) => ({
scopeId: id,
...(label ? { label } : {}),
sessions: sessionN.get(id) ?? 0,
backgroundSessions: backgroundN.get(id) ?? 0,
lastActivity: lastActivityBy.get(id) ?? 0,
lastConversationActivity: lastConversationBy.get(id) ?? 0,
lastMessage: lastMessageBy.get(id) ?? "",
crons: cronN.get(id) ?? 0,
deployments: deployN.get(id) ?? 0,
skills: skillN.get(id) ?? 0,
}));
const scopes = [...labels].map(([id, label]) => {
const environment = environmentById.get(id);
const attachment = attachmentByScope.get(id);
return {
scopeId: id,
...(label ? { label } : {}),
...(environment?.name ? { environmentName: environment.name } : {}),
...(attachment
? {
environmentAttachment: {
environmentId: attachment.id,
environmentName: attachment.name,
},
}
: {}),
sessions: sessionN.get(id) ?? 0,
backgroundSessions: backgroundN.get(id) ?? 0,
lastActivity: lastActivityBy.get(id) ?? 0,
lastConversationActivity: lastConversationBy.get(id) ?? 0,
lastMessage: lastMessageBy.get(id) ?? "",
crons: cronN.get(id) ?? 0,
deployments: deployN.get(id) ?? 0,
skills: skillN.get(id) ?? 0,
};
});
scopes.sort(
(a, b) =>
b.lastActivity - a.lastActivity ||
b.sessions - a.sessions ||
b.backgroundSessions - a.backgroundSessions ||
a.scopeId.localeCompare(b.scopeId),
);
return sendJson(res, 200, { scopeId: scope, scopes });
return sendJson(res, 200, { scopeId: scope, scopes, environments });
}

interface ScopeEnvironmentMetadata {
environment?: { id: string; name: string; ownerActorId: string | null };
environmentAttachment?: { environmentId: string; environmentName: string | null };
}

async function scopeEnvironmentMetadata(deps: ApiCtx["deps"], targetScope: string): Promise<ScopeEnvironmentMetadata> {
const store = deps.environments;
if (!store) return {};

const [environment, attachment] = await Promise.all([store.get(targetScope), store.getAttachment(targetScope)]);
const metadata: ScopeEnvironmentMetadata = {};
if (environment?.name) {
metadata.environment = {
id: environment.id,
name: environment.name,
ownerActorId: environment.ownerActorId,
};
}
if (attachment) {
const attachedEnvironment = await store.get(attachment.environmentId);
metadata.environmentAttachment = {
environmentId: attachment.environmentId,
environmentName: attachedEnvironment?.name ?? null,
};
}
return metadata;
}

export async function getScopeConfig(ctx: ApiCtx): Promise<void> {
Expand All @@ -202,6 +258,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise<void> {
if (!actor) return;
await deps.config.refreshScope(targetScope);
audit(deps, { principalId: actor.id, action: "config.read", resource: "config", scopeLabel: targetScope });
const environmentMetadata = await scopeEnvironmentMetadata(deps, targetScope);
const serviceCredentials = await Promise.all(
(deps.serviceCreds ? await deps.serviceCreds.listServiceCredentials(targetScope) : []).map(async (c) => {
const usage = (await deps.credentialUsage?.list({ slug: c.slug, limit: 5000 })) ?? [];
Expand Down Expand Up @@ -261,6 +318,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise<void> {
};
return sendJson(res, 200, {
scopeId: targetScope,
...environmentMetadata,
...values,
soulVersion: deps.config.soulVersion(targetScope),
soulHistory: deps.config.soulHistory(targetScope),
Expand Down
33 changes: 33 additions & 0 deletions test/admin-scopes-directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ function start() {
admin: built.admin,
auditLog: built.auditLog,
sessions: built.sessions,
config: built.config,
environments: built.environments,
});
server.listen(0);
const base = `http://localhost:${(server.address() as AddressInfo).port}`;
Expand Down Expand Up @@ -118,3 +120,34 @@ test("scopes without a session label fall back to the org directory (people's na
await s.close();
}
});

test("the admin scope directory exposes named environments and attached scopes", async () => {
const s = start();
try {
await s.built.app.upsertChannels([
{ channelId: "A", name: "source" },
{ channelId: "B", name: "attached" },
]);
await s.built.app.createEnvironment({ scopeId: "channel:A", name: "A-permanent", actorId: "U1" });
await s.built.app.attachScope({ scopeId: "channel:B", environmentId: "channel:A", actorId: "U1" });

const directory = await json(await fetch(`${s.base}/v1/admin/scopes`, { headers: ALICE_ADMIN }));
const byId = new Map(directory.scopes.map((row: any) => [row.scopeId, row]));
assert.deepEqual(directory.environments, [
{ id: "channel:A", name: "A-permanent", ownerActorId: "U1", attachedScopes: ["channel:B"] },
]);
assert.equal((byId.get("channel:A") as any).environmentName, "A-permanent");
assert.deepEqual((byId.get("channel:B") as any).environmentAttachment, {
environmentId: "channel:A",
environmentName: "A-permanent",
});

const attached = await json(await fetch(`${s.base}/v1/admin/scopes/channel:B`, { headers: ALICE_ADMIN }));
assert.deepEqual(attached.environmentAttachment, {
environmentId: "channel:A",
environmentName: "A-permanent",
});
} finally {
await s.close();
}
});
Loading