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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user