diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html
index e7c0367b0..d06d4996a 100644
--- a/plugins/admin/public/index.html
+++ b/plugins/admin/public/index.html
@@ -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;
@@ -3987,6 +4011,13 @@
Effective state
@@ -5717,6 +5748,7 @@ Confirm governance change
});
let scopeDir = null;
+ let environmentDir = [];
let scopeDirNote = "Loading scopes…";
async function loadScopeDirectory() {
const r = await api("GET", "/api/scopes");
@@ -5728,6 +5760,7 @@ Confirm governance change
}
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");
@@ -6244,6 +6277,19 @@ Confirm governance change
);
}
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;
@@ -6259,6 +6305,7 @@ Confirm governance change
);
return;
}
+ renderEnvironmentNotice(r.data);
renderGovernanceOverview(r.data);
syncGovernanceSectionNav();
loadedCommandPolicyPresent = r.data.commandPolicy != null;
@@ -11451,6 +11498,21 @@ Confirm governance change
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) => {
diff --git a/plugins/admin/test/environments.test.ts b/plugins/admin/test/environments.test.ts
new file mode 100644
index 000000000..3ec8c9fb8
--- /dev/null
+++ b/plugins/admin/test/environments.test.ts
@@ -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/);
+});
diff --git a/skills-seed/google-workspace/scripts/gmail.py b/skills-seed/google-workspace/scripts/gmail.py
index 110c5a36c..f07d7018e 100755
--- a/skills-seed/google-workspace/scripts/gmail.py
+++ b/skills-seed/google-workspace/scripts/gmail.py
@@ -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
@@ -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:
diff --git a/src/api/routes/admin/scope-config.ts b/src/api/routes/admin/scope-config.ts
index 22acc4f7e..17d206168 100644
--- a/src/api/routes/admin/scope-config.ts
+++ b/src/api/routes/admin/scope-config.ts
@@ -132,10 +132,25 @@ export async function listAdminScopes(ctx: ApiCtx): Promise {
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 => {
@@ -171,18 +186,31 @@ export async function listAdminScopes(ctx: ApiCtx): Promise {
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 ||
@@ -190,7 +218,35 @@ export async function listAdminScopes(ctx: ApiCtx): Promise {
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 {
+ 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 {
@@ -202,6 +258,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise {
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 })) ?? [];
@@ -261,6 +318,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise {
};
return sendJson(res, 200, {
scopeId: targetScope,
+ ...environmentMetadata,
...values,
soulVersion: deps.config.soulVersion(targetScope),
soulHistory: deps.config.soulHistory(targetScope),
diff --git a/test/admin-scopes-directory.test.ts b/test/admin-scopes-directory.test.ts
index dcb280e50..b604750af 100644
--- a/test/admin-scopes-directory.test.ts
+++ b/test/admin-scopes-directory.test.ts
@@ -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}`;
@@ -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();
+ }
+});
diff --git a/test/gmail-mime.test.ts b/test/gmail-mime.test.ts
index e4ddbdf8d..0f6930b8a 100644
--- a/test/gmail-mime.test.ts
+++ b/test/gmail-mime.test.ts
@@ -1,6 +1,8 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
+import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
import { join } from "node:path";
const SCRIPT = join(process.cwd(), "skills-seed", "google-workspace", "scripts", "gmail.py");
@@ -73,3 +75,52 @@ test("intra-paragraph line breaks survive as
in the html mirror", { skip: !
assert.ok(html, "html part exists");
assert.ok(html.includes("Two lines
in one paragraph"), "intra-paragraph breaks become
");
});
+
+test(
+ "api calls go through curl, which can tunnel the sandbox's https CONNECT egress proxy",
+ { skip: !havePython },
+ () => {
+ const dir = mkdtempSync(join(tmpdir(), "gmail-curl-"));
+ const argsFile = join(dir, "args.json");
+ const stub = join(dir, "curl");
+ writeFileSync(
+ stub,
+ `#!/usr/bin/env python3\nimport json, sys\nbody = sys.stdin.read() if "@-" in sys.argv else ""\n` +
+ `json.dump({"argv": sys.argv[1:], "stdin": body}, open(${JSON.stringify(argsFile)}, "w"))\n` +
+ `print('{"id":"m1","threadId":"t1"}\\n200', end="")\n`,
+ );
+ chmodSync(stub, 0o755);
+ const out = execFileSync("python3", [SCRIPT, "send-draft", "d1"], {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PATH: `${dir}:${process.env.PATH}`,
+ VAULT_TOKEN_GMAIL_GOOGLEAPIS_COM: "tok",
+ HTTPS_PROXY: "https://proxy.internal:3128",
+ },
+ });
+ assert.deepEqual(JSON.parse(out), { id: "m1", threadId: "t1" });
+ const recorded = JSON.parse(readFileSync(argsFile, "utf8"));
+ const sendUrl = recorded.argv.find((a: string) => a.startsWith("https://"));
+ assert.equal(sendUrl, "https://gmail.googleapis.com/gmail/v1/users/me/drafts/send");
+ assert.ok(recorded.argv.includes("Authorization: Bearer tok"));
+ assert.equal(recorded.stdin, '{"id": "d1"}');
+ },
+);
+
+test("non-2xx responses exit with the status and body excerpt", { skip: !havePython }, () => {
+ const dir = mkdtempSync(join(tmpdir(), "gmail-curl-"));
+ const stub = join(dir, "curl");
+ writeFileSync(
+ stub,
+ `#!/usr/bin/env python3\nimport sys\nsys.stdin.read()\nprint('{"error":"nope"}\\n403', end="")\n`,
+ );
+ chmodSync(stub, 0o755);
+ const res = spawnSync("python3", [SCRIPT, "send-draft", "d1"], {
+ encoding: "utf8",
+ env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, VAULT_TOKEN_GMAIL_GOOGLEAPIS_COM: "tok" },
+ });
+ assert.notEqual(res.status, 0);
+ assert.ok(res.stderr.includes("gmail api 403"));
+ assert.ok(res.stderr.includes("nope"));
+});