Let a bot and a workspace set their own envs

Envs had three scopes, general, workspace and computer. A bot had no
scope of its own, so the only way to give one worker a key was to set it
on a computer it happened to share.

The envs table gets a bot_id, and resolution now layers bot over
computer over workspace over general, so a bot's own key wins wherever it
runs. Every computer call site passes the run's bot_id, and grabRunEnvs
does the same for the {{env.KEY}} a custom tool or an MCP server's headers
read.

A bot's and a workspace's settings page can now edit their own envs, in
place, through the same save-env and delete-env routes: sending `scoped`
makes them answer with just that scope instead of the whole install's
overview. Super admins only, like the Environment page.
This commit is contained in:
2026-10-22 08:30:40 +01:00
parent 7a3d2df213
commit 96735de48f
38 changed files with 551 additions and 135 deletions
@@ -11,6 +11,7 @@ export type DecryptedEnv = {
paradigm: EnvParadigm;
workspace_id: number | null;
computer_id: number | null;
bot_id: number | null;
secret: boolean;
expose_to_computers: boolean;
};
@@ -52,6 +53,7 @@ async function load() {
paradigm: (row.paradigm || "general") as EnvParadigm,
workspace_id: typeof row.workspace_id == "number" ? row.workspace_id : null,
computer_id: typeof row.computer_id == "number" ? row.computer_id : null,
bot_id: typeof row.bot_id == "number" ? row.bot_id : null,
secret: row.secret != 0,
expose_to_computers: row.expose_to_computers != 0,
})),
@@ -4,6 +4,10 @@ import resolveScopedEnvs from "./resolve-scoped-envs";
type Params = {
computer_id: number | string;
/**
* The bot the run is for: its own envs beat everything else.
*/
bot_id?: number | string | null;
/**
* The workspace the run is in. Its envs apply when the computer is
* linked to it (and it isn't archived); outside a workspace only the
@@ -15,7 +19,7 @@ type Params = {
/**
* The envs a run on this computer gets (`resolveScopedEnvs`).
*/
export default async function grabComputerEnvs({ computer_id, workspace_id }: Params) {
export default async function grabComputerEnvs({ computer_id, bot_id, workspace_id }: Params) {
const linked = workspace_id
? (
await BunSQLite.sql<{ id: number }>({
@@ -29,6 +33,7 @@ export default async function grabComputerEnvs({ computer_id, workspace_id }: Pa
return resolveScopedEnvs({
envs: await grabAllEnvs(),
bot_id,
computer_id: Number(computer_id),
workspace_ids: linked?.id ? [Number(linked.id)] : [],
});
@@ -1,73 +1,64 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
import { EnvDict } from "@/src/dict/env-dict";
import type { EnvsOverviewPayload } from "@/src/types";
import grabAllEnvs from "./grab-all-envs";
import grabAllEnvs, { type DecryptedEnv } from "./grab-all-envs";
type Params = {
/**
* The super admin looking: workspaces and computers belong to the
* install, so they see every one.
* The super admin looking: workspaces, computers and bots belong to
* the install, so they see every one.
*/
user_id: number;
};
/**
* The Environment settings page: where envs can be set (general, every
* workspace and computer), every env (secret values left out),
* workspace, bot and computer), every env (secret values left out),
* and the app settings with where each comes from.
*/
export default async function grabEnvsOverview(_params: Params): Promise<EnvsOverviewPayload> {
const [workspaces, computers, envs] = await Promise.all([
const [workspaces, bots, computers, envs] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, name, archived FROM workspaces ORDER BY archived ASC, id ASC`,
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, name, kind FROM bots ORDER BY kind = 'coordinator' DESC, id ASC`,
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT id, name, kind FROM computers ORDER BY kind = 'main' DESC, id ASC`,
}),
grabAllEnvs(),
]);
const workspace_ids = new Set((workspaces.payload || []).map((w) => Number(w.id)));
const computer_ids = new Set((computers.payload || []).map((c) => Number(c.id)));
const known_keys = new Set(EnvDict.map((env) => env.key));
const visible = envs.filter(
(env) =>
env.paradigm == "general" ||
(env.workspace_id !== null && workspace_ids.has(env.workspace_id)) ||
(env.computer_id !== null && computer_ids.has(env.computer_id)),
);
return {
scopes: [
{ paradigm: "general", id: null, title: "General" },
...(workspaces.payload || []).map((workspace) => ({
paradigm: "workspace" as const,
id: Number(workspace.id),
title: `Workspace: ${workspace.name}${workspace.archived ? " (archived)" : ""}`,
})),
...(computers.payload || []).map((computer) => ({
paradigm: "computer" as const,
id: Number(computer.id),
title: `Computer: ${computer.name || computer.kind}`,
})),
...[
...(workspaces.payload || []).map((workspace) => ({
paradigm: "workspace" as const,
id: Number(workspace.id),
title: `Workspace: ${workspace.name}${workspace.archived ? " (archived)" : ""}`,
})),
...(bots.payload || []).map((bot) => ({
paradigm: "bot" as const,
id: Number(bot.id),
title: `Bot: ${bot.name || bot.kind}`,
})),
...(computers.payload || []).map((computer) => ({
paradigm: "computer" as const,
id: Number(computer.id),
title: `Computer: ${computer.name || computer.kind}`,
})),
].sort(compareScopes),
],
envs: visible.map((env) => ({
id: env.id,
key: env.key,
description: env.description,
paradigm: env.paradigm,
workspace_id: env.workspace_id,
computer_id: env.computer_id,
secret: env.secret,
expose_to_computers: env.expose_to_computers,
value: env.secret ? undefined : env.value,
known: known_keys.has(env.key),
})),
envs: envs.map((env) => publicEnv({ env, known_keys })),
known: EnvDict.map((env) => ({
...env,
saved: envs.some((saved) => saved.paradigm == "general" && saved.key == env.key),
@@ -75,3 +66,41 @@ export default async function grabEnvsOverview(_params: Params): Promise<EnvsOve
})),
};
}
/**
* As the page lists them, secret values left out.
*/
export function publicEnv({
env,
known_keys,
}: {
env: DecryptedEnv;
known_keys: Set<string>;
}) {
return {
id: env.id,
key: env.key,
description: env.description,
paradigm: env.paradigm,
workspace_id: env.workspace_id,
computer_id: env.computer_id,
bot_id: env.bot_id,
secret: env.secret,
expose_to_computers: env.expose_to_computers,
value: env.secret ? undefined : env.value,
known: known_keys.has(env.key),
};
}
const ScopeOrder = ["workspace", "bot", "computer"];
/**
* Groups the scopes by paradigm (workspaces, then bots, then computers),
* each in id order.
*/
function compareScopes(a: { paradigm: string; id: number | null }, b: { paradigm: string; id: number | null }) {
return (
ScopeOrder.indexOf(a.paradigm) - ScopeOrder.indexOf(b.paradigm) ||
Number(a.id || 0) - Number(b.id || 0)
);
}
@@ -0,0 +1,58 @@
import { EnvDict, type EnvParadigm } from "@/src/dict/env-dict";
import type { ScopedEnvsPayload } from "@/src/types";
import grabUserBot from "../bots/grab-user-bot";
import grabUserWorkspace from "../workspaces/grab-user-workspace";
import grabAllEnvs from "./grab-all-envs";
import { publicEnv } from "./grab-envs-overview";
type Params = {
paradigm: Extract<EnvParadigm, "workspace" | "bot">;
owner_id: number | string;
/**
* Who's looking. Envs are super admin only, like the Environment page;
* everyone else gets `null`, so the group isn't rendered at all.
*/
user_id: number | string;
/**
* Whether they're a super admin.
*/
can_manage: boolean;
};
/**
* One bot's or workspace's own envs, for its settings page: only what's
* set there, never the whole install's.
*/
export default async function grabScopedEnvs({
paradigm,
owner_id,
user_id,
can_manage,
}: Params): Promise<ScopedEnvsPayload | null> {
if (!can_manage) return null;
const owner =
paradigm == "bot"
? await grabUserBot({ bot_id: owner_id, user_id })
: await grabUserWorkspace({ workspace_id: owner_id, user_id });
if (!owner?.id) return null;
const known_keys = new Set(EnvDict.map((env) => env.key));
const envs = (await grabAllEnvs()).filter((env) =>
paradigm == "bot"
? env.paradigm == "bot" && env.bot_id == Number(owner_id)
: env.paradigm == "workspace" && env.workspace_id == Number(owner_id),
);
return {
scope: {
paradigm,
id: Number(owner_id),
title: paradigm == "bot" ? "Bot" : "Workspace",
},
envs: envs.map((env) => publicEnv({ env, known_keys })),
can_manage,
};
}
@@ -10,6 +10,7 @@ const env = (fields: Partial<DecryptedEnv> & { key: string; value: string }): De
paradigm: "general",
workspace_id: null,
computer_id: null,
bot_id: null,
secret: true,
expose_to_computers: true,
...fields,
@@ -58,6 +59,20 @@ describe("resolveScopedEnvs", () => {
});
});
test("the bot's own env beats everything", () => {
const envs = [
env({ key: "TOKEN", value: "general" }),
env({ key: "TOKEN", value: "workspace", paradigm: "workspace", workspace_id: 2 }),
env({ key: "TOKEN", value: "computer", paradigm: "computer", computer_id: 7 }),
env({ key: "TOKEN", value: "bot", paradigm: "bot", bot_id: 4 }),
env({ key: "OTHER_BOT", value: "no", paradigm: "bot", bot_id: 5 }),
];
expect(
values(resolveScopedEnvs({ envs, bot_id: 4, computer_id: 7, workspace_ids: [2] })),
).toEqual({ TOKEN: "bot" });
});
test("leaves out unexposed envs and app settings", () => {
const envs = [
env({ key: "HIDDEN", value: "x", expose_to_computers: false }),
@@ -3,22 +3,28 @@ import type { DecryptedEnv } from "./grab-all-envs";
type Params = {
envs: DecryptedEnv[];
computer_id: number;
/**
* The bot the run is for: its own envs beat everything else.
*/
bot_id?: number | string | null;
computer_id?: number | string | null;
/**
* The workspaces whose envs apply, in order of precedence (first wins).
*/
workspace_ids: number[];
workspace_ids?: (number | string)[];
};
/**
* The envs that reach one computer, one per key: its own beat its
* workspaces' (the first listed workspace wins a tie), which beat general
* ones. Only envs exposed to computers; app settings never are.
* The envs that reach one run, one per key: the bot's own beat the
* computer's, which beat its workspaces' (the first listed workspace wins
* a tie), which beat general ones. Only envs exposed to computers; app
* settings never are.
*/
export default function resolveScopedEnvs({
envs,
bot_id,
computer_id,
workspace_ids,
workspace_ids = [],
}: Params): DecryptedEnv[] {
const app_keys = new Set(EnvDict.map((env) => env.key));
const exposed = envs.filter(
@@ -29,9 +35,18 @@ export default function resolveScopedEnvs({
const layers = [
exposed.filter((env) => env.paradigm == "general"),
...[...workspace_ids].reverse().map((id) =>
exposed.filter((env) => env.paradigm == "workspace" && env.workspace_id == id),
exposed.filter(
(env) => env.paradigm == "workspace" && env.workspace_id == Number(id),
),
),
exposed.filter((env) => env.paradigm == "computer" && env.computer_id == computer_id),
computer_id
? exposed.filter(
(env) => env.paradigm == "computer" && env.computer_id == Number(computer_id),
)
: [],
bot_id
? exposed.filter((env) => env.paradigm == "bot" && env.bot_id == Number(bot_id))
: [],
];
for (const layer of layers) {
+23 -7
View File
@@ -28,6 +28,7 @@ type Params = {
paradigm: EnvParadigm;
workspace_id?: number | null;
computer_id?: number | null;
bot_id?: number | null;
secret: boolean;
expose_to_computers: boolean;
};
@@ -47,6 +48,7 @@ export default async function saveEnv({
paradigm,
workspace_id,
computer_id,
bot_id,
secret,
expose_to_computers,
}: Params) {
@@ -66,14 +68,22 @@ export default async function saveEnv({
throw new AppError(`${final_key} is an app setting, so it can only be set in General.`);
}
const scope = await resolveScope({ user_id, paradigm, workspace_id, computer_id });
const scope = await resolveScope({ user_id, paradigm, workspace_id, computer_id, bot_id });
const duplicate = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
sql: `SELECT id FROM envs WHERE key = ? AND paradigm = ?
AND COALESCE(workspace_id, 0) = ? AND COALESCE(computer_id, 0) = ?
AND COALESCE(bot_id, 0) = ?
AND id != ? LIMIT 1`,
values: [final_key, paradigm, scope.workspace_id ?? 0, scope.computer_id ?? 0, env_id ?? 0],
values: [
final_key,
paradigm,
scope.workspace_id ?? 0,
scope.computer_id ?? 0,
scope.bot_id ?? 0,
env_id ?? 0,
],
})
).payload?.[0];
@@ -87,6 +97,7 @@ export default async function saveEnv({
paradigm,
workspace_id: scope.workspace_id,
computer_id: scope.computer_id,
bot_id: scope.bot_id,
secret: (known ? known.secret : secret) ? 1 : 0,
expose_to_computers: !known && expose_to_computers ? 1 : 0,
};
@@ -140,18 +151,21 @@ async function resolveScope({
paradigm,
workspace_id,
computer_id,
bot_id,
}: {
user_id: number;
paradigm: EnvParadigm;
workspace_id?: number | null;
computer_id?: number | null;
bot_id?: number | null;
}) {
if (paradigm == "general") {
return { workspace_id: null, computer_id: null };
return { workspace_id: null, computer_id: null, bot_id: null };
}
const table = paradigm == "workspace" ? "workspaces" : "computers";
const id = paradigm == "workspace" ? workspace_id : computer_id;
const table =
paradigm == "workspace" ? "workspaces" : paradigm == "bot" ? "bots" : "computers";
const id = paradigm == "workspace" ? workspace_id : paradigm == "bot" ? bot_id : computer_id;
const owned = id
? (
@@ -167,6 +181,8 @@ async function resolveScope({
}
return paradigm == "workspace"
? { workspace_id: Number(id), computer_id: null }
: { workspace_id: null, computer_id: Number(id) };
? { workspace_id: Number(id), computer_id: null, bot_id: null }
: paradigm == "bot"
? { workspace_id: null, computer_id: null, bot_id: Number(id) }
: { workspace_id: null, computer_id: Number(id), bot_id: null };
}
@@ -2,6 +2,10 @@ import { EnvDict } from "@/src/dict/env-dict";
import grabAllEnvs, { type DecryptedEnv } from "../envs/grab-all-envs";
type Params = {
/**
* The bot the run is for: its own envs beat everything else.
*/
bot_id?: number | string | null;
/**
* The workspace the run is in: its envs beat general ones.
*/
@@ -10,11 +14,11 @@ type Params = {
/**
* The envs an MCP server's or custom tool's templates (`{{env.KEY}}`) read
* on one run, one per key: the workspace's beat general ones. App settings
* (`EnvDict`) never are. Unlike computers' envs, they don't have to be set
* on computers: the app fills them in itself.
* on one run, one per key: the bot's own beat the workspace's, which beat
* general ones. App settings (`EnvDict`) never are. Unlike computers' envs,
* they don't have to be set on computers: the app fills them in itself.
*/
export default async function grabRunEnvs({ workspace_id }: Params = {}): Promise<DecryptedEnv[]> {
export default async function grabRunEnvs({ bot_id, workspace_id }: Params = {}): Promise<DecryptedEnv[]> {
const app_keys = new Set(EnvDict.map((env) => env.key));
const envs = (await grabAllEnvs()).filter((env) => !app_keys.has(env.key));
const resolved = new Map<string, DecryptedEnv>();
@@ -22,7 +26,15 @@ export default async function grabRunEnvs({ workspace_id }: Params = {}): Promis
for (const env of envs.filter((e) => e.paradigm == "general")) resolved.set(env.key, env);
if (workspace_id) {
for (const env of envs.filter((e) => e.paradigm == "workspace" && e.workspace_id == Number(workspace_id))) {
for (const env of envs.filter(
(e) => e.paradigm == "workspace" && e.workspace_id == Number(workspace_id),
)) {
resolved.set(env.key, env);
}
}
if (bot_id) {
for (const env of envs.filter((e) => e.paradigm == "bot" && e.bot_id == Number(bot_id))) {
resolved.set(env.key, env);
}
}