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
@@ -1,22 +0,0 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { EnvsOverviewPayload } from "@/src/types";
type Params = {
/**
* Under `/api/admin/envs/`.
*/
path: "save-env" | "delete-env";
body: Record<string, unknown>;
};
/**
* Posts to an env API route. Every route answers with the fresh page data
* in `singleRes`, so the page updates in place.
*/
export default async function postEnvApi({ path, body }: Params) {
return await fetchApi<Record<string, unknown>, APIResponseObject<EnvsOverviewPayload>>(
`/api/admin/envs/${path}`,
{ method: "POST", body },
);
}
@@ -1,153 +0,0 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types";
import SettingsStatus from "../../(partials)/settings-status";
import postEnvApi from "../(functions)/post-env-api";
type Props = {
scope: EnvScope;
/**
* Edits this env; adds one when not given.
*/
env?: PublicEnv;
/**
* Sets this app setting: its key, description and flags are fixed.
*/
known?: KnownEnvStatus;
onSaved: (overview: EnvsOverviewPayload, msg: string) => void;
onCancel: () => void;
};
/**
* Adds or edits an env in one scope. A saved secret is never shown; typing
* a new value replaces it, leaving it empty keeps it.
*/
export default function EnvForm({ scope, env, known, onSaved, onCancel }: Props) {
const is_known = Boolean(known || env?.known);
const [key, setKey] = useState(env?.key || known?.key || "");
const [value, setValue] = useState(env && !env.secret ? env.value || "" : "");
const [description, setDescription] = useState(env?.description || "");
const [secret, setSecret] = useState(env?.secret ?? known?.secret ?? true);
const [expose, setExpose] = useState(env?.expose_to_computers ?? true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const id = `env-${env?.id || known?.key || "new"}`;
const keeps_secret = Boolean(env?.secret);
const submit = async () => {
if (loading) return;
if (!window.confirm(env?.id ? `Save ${env.key}?` : `Add ${key || "this env"}?`)) return;
setLoading(true);
setError(undefined);
const res = await postEnvApi({
path: "save-env",
body: {
env_id: env?.id,
key,
value: keeps_secret && !value ? undefined : value,
description: is_known ? undefined : description,
paradigm: scope.paradigm,
workspace_id: scope.paradigm == "workspace" ? scope.id : null,
computer_id: scope.paradigm == "computer" ? scope.id : null,
secret,
expose_to_computers: expose,
},
});
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't save the env.");
return;
}
onSaved(res.singleRes, res.msg || "Saved.");
};
return (
<Form className="w-full gap-4! items-stretch py-4" onSubmit={submit}>
<Input
id={`${id}-key`}
title="Key"
label="Key"
showLabel
placeholder="GITHUB_TOKEN"
defaultValue={key}
disabled={Boolean(env) || is_known}
autoComplete="off"
onChange={(e) => setKey(e.target.value)}
/>
<Input
id={`${id}-value`}
title="Value"
label="Value"
showLabel
type={secret ? "password" : "text"}
autoComplete="off"
defaultValue={value}
placeholder={keeps_secret ? "Saved. Type a new value to replace it." : ""}
info="Encrypted before it's stored."
onChange={(e) => setValue(e.target.value)}
/>
{is_known ? null : (
<>
<Input
id={`${id}-description`}
title="Description"
label="Description"
showLabel
placeholder="A token for the company GitHub"
defaultValue={description}
info="Bots see the key and this description, never the value."
onChange={(e) => setDescription(e.target.value)}
/>
<Checkbox
title="Secret"
label="Secret: hidden once saved, and blanked out of everything bots read"
checked={secret}
changeHandler={setSecret}
/>
<Checkbox
title="Set on computers"
label="Set on computers, for bots' commands and code"
checked={expose}
changeHandler={setExpose}
/>
</>
)}
<Stack className="gap-3! items-stretch">
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
<Row className="gap-2! flex-nowrap justify-end">
<Button
title="Cancel"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={loading}
onClick={onCancel}
>
Cancel
</Button>
<Button
title={env ? "Save env" : "Add env"}
type="submit"
size="small"
loading={loading}
disabled={loading}
>
{env ? "Save env" : "Add env"}
</Button>
</Row>
</Stack>
</Form>
);
}
@@ -1,49 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { Pencil, Plus, Trash2 } from "lucide-react";
type Props = {
name: string;
/**
* "set" for an app setting that isn't saved yet.
*/
mode: "edit" | "set";
busy?: boolean;
onEdit: () => void;
onRemove?: () => void;
};
/**
* Edit (or Set) and Remove for one env row.
*/
export default function EnvRowActions({ name, mode, busy, onEdit, onRemove }: Props) {
return (
<Row className="gap-1! flex-nowrap shrink-0">
<Button
title={mode == "set" ? `Set ${name}` : `Edit ${name}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={onEdit}
>
{mode == "set" ? <Plus size={16} aria-hidden /> : <Pencil size={16} aria-hidden />}
</Button>
{onRemove ? (
<Button
title={`Remove ${name}`}
type="button"
variant="ghost"
color="gray"
size="small"
loading={busy}
disabled={busy}
onClick={onRemove}
>
<Trash2 size={16} aria-hidden />
</Button>
) : null}
</Row>
);
}
@@ -1,92 +0,0 @@
import { useState } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types";
import postEnvApi from "../(functions)/post-env-api";
import EnvForm from "./env-form";
import EnvRowActions from "./env-row-actions";
type Props = {
scope: EnvScope;
/**
* The saved env, if any. An app setting may have none yet.
*/
env?: PublicEnv;
known?: KnownEnvStatus;
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
onError: (msg: string) => void;
};
/**
* One env: its key, description and value (or "secret"), with Edit and
* Remove. Edit opens the form in place.
*/
export default function EnvRow({ scope, env, known, onChanged, onError }: Props) {
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
const key = env?.key || known?.key || "";
const remove = async () => {
if (!env || busy) return;
if (!window.confirm(`Remove ${key}? Computers drop it on their next run.`)) return;
setBusy(true);
const res = await postEnvApi({ path: "delete-env", body: { env_id: env.id } });
setBusy(false);
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed.");
else onError(res.msg || "Couldn't remove the env.");
};
if (editing) {
return (
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
<EnvForm
scope={scope}
env={env}
known={known}
onSaved={(overview, msg) => {
setEditing(false);
onChanged(overview, msg);
}}
onCancel={() => setEditing(false)}
/>
</Stack>
);
}
const status = env
? env.secret
? "Secret, saved"
: env.value || "Empty"
: known?.from_process
? "From the server's environment"
: "Not set";
const flags = env && !env.known && !env.expose_to_computers ? " · not on computers" : "";
return (
<Row className="w-full gap-3! flex-nowrap items-start justify-between py-3 border-0 border-b border-solid border-hairline last:border-b-0">
<Stack className="gap-0.5! min-w-0">
<Span className="text-sm font-bold leading-5 text-ink truncate">{key}</Span>
{env?.description || known?.description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
{known?.description || env?.description}
</Span>
) : null}
<Span className="text-[13px] leading-[18px] text-ink-secondary break-all">
{status}
{flags}
</Span>
</Stack>
<EnvRowActions
name={key}
mode={env ? "edit" : "set"}
busy={busy}
onEdit={() => setEditing(true)}
onRemove={env ? remove : undefined}
/>
</Row>
);
}
@@ -12,14 +12,14 @@ type Props = {
};
/**
* Where the envs below apply: general, a workspace or a computer. Also
* shows the last change's status.
* Where the envs below apply: general, a workspace, a bot or a computer.
* Also shows the last change's status.
*/
export default function EnvScopeSelect({ scopes, value, onChange, status }: Props) {
return (
<SettingsGroup
title="Where"
description="A computer's own envs beat its workspace's, which beat general ones."
description="A bot's own envs beat everything. Then the computer's own, then the workspace's, then general."
>
<Select
id="env-scope"
@@ -1,78 +0,0 @@
import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Plus } from "lucide-react";
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types";
import SettingsGroup from "../../(partials)/settings-group";
import EnvForm from "./env-form";
import EnvRow from "./env-row";
type Props = {
scope: EnvScope;
/**
* The scope's own envs (app settings left out).
*/
envs: PublicEnv[];
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
onError: (msg: string) => void;
};
const Descriptions: Record<EnvScope["paradigm"], string> = {
general: "Set on every computer. A workspace or computer can set the same key to override it.",
workspace: "Set on this workspace's computers when its bots use them. Beats general, loses to a computer's own.",
computer: "Set on this computer only. Beats workspace and general.",
};
/**
* The envs bots and computers use, in one scope, with an add form.
*/
export default function EnvsGroup({ scope, envs, onChanged, onError }: Props) {
const [adding, setAdding] = useState(false);
return (
<SettingsGroup title="Envs" description={Descriptions[scope.paradigm]}>
<Stack className="w-full gap-0! items-stretch">
{envs.map((env) => (
<EnvRow
key={env.id}
scope={scope}
env={env}
onChanged={onChanged}
onError={onError}
/>
))}
{envs.length ? null : (
<Span className="text-[13px] leading-5 text-ink-secondary py-3">
Nothing set here yet.
</Span>
)}
</Stack>
{adding ? (
<EnvForm
scope={scope}
onSaved={(overview, msg) => {
setAdding(false);
onChanged(overview, msg);
}}
onCancel={() => setAdding(false)}
/>
) : (
<Row className="gap-3! items-center justify-end">
<Button
title="Add an env"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => setAdding(true)}
>
<Plus size={16} aria-hidden />
Add an env
</Button>
</Row>
)}
</SettingsGroup>
);
}
@@ -1,12 +1,12 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types";
import type { EnvScope, EnvsMutationResult, EnvsOverviewPayload, PublicEnv } from "@/src/types";
import EnvRow from "../../(partials)/envs/env-row";
import SettingsGroup from "../../(partials)/settings-group";
import EnvRow from "./env-row";
type Props = {
scope: EnvScope;
overview: EnvsOverviewPayload;
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
onChanged: (data: EnvsMutationResult, msg: string) => void;
onError: (msg: string) => void;
};
@@ -1,14 +1,14 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { EnvsOverviewPayload } from "@/src/types";
import type { EnvsMutationResult, EnvsOverviewPayload } from "@/src/types";
import EnvsGroup from "../../(partials)/envs/envs-group";
import EnvScopeSelect from "../(partials)/env-scope-select";
import EnvsGroup from "../(partials)/envs-group";
import KnownEnvsGroup from "../(partials)/known-envs-group";
import scopeValue, { envInScope } from "../(utils)/scope-value";
/**
* Pick where (general, a workspace, a computer), then that scope's envs.
* General also lists the app settings. Every change saves at once and
* Pick where (general, a workspace, a bot or a computer), then that scope's
* envs. General also lists the app settings. Every change saves at once and
* refreshes the page data.
*/
export default function EnvironmentSettingsSection() {
@@ -26,8 +26,8 @@ export default function EnvironmentSettingsSection() {
const scope =
overview.scopes.find((s) => scopeValue({ scope: s }) == selected) || overview.scopes[0]!;
const changed = (next: EnvsOverviewPayload, msg: string) => {
setOverview(next);
const changed = (result: EnvsMutationResult, msg: string) => {
if (result.overview) setOverview(result.overview);
setStatus({ success: true, msg });
setVersion((prev) => prev + 1);
};
@@ -9,6 +9,7 @@ const env = (fields: Partial<PublicEnv>): PublicEnv => ({
paradigm: "general",
workspace_id: null,
computer_id: null,
bot_id: null,
secret: true,
expose_to_computers: true,
known: false,
@@ -19,6 +20,7 @@ describe("scopeValue", () => {
test("names each scope", () => {
expect(scopeValue({ scope: { paradigm: "general", id: null } })).toBe("general");
expect(scopeValue({ scope: { paradigm: "computer", id: 4 } })).toBe("computer:4");
expect(scopeValue({ scope: { paradigm: "bot", id: 1 } })).toBe("bot:1");
});
});
@@ -30,4 +32,11 @@ describe("envInScope", () => {
expect(envInScope({ env: env({ paradigm: "workspace", workspace_id: 3 }), scope })).toBe(false);
expect(envInScope({ env: env({}), scope })).toBe(false);
});
test("matches a bot's own envs", () => {
const scope = { paradigm: "bot" as const, id: 1, title: "" };
expect(envInScope({ env: env({ paradigm: "bot", bot_id: 1 }), scope })).toBe(true);
expect(envInScope({ env: env({ paradigm: "bot", bot_id: 2 }), scope })).toBe(false);
});
});
@@ -1,7 +1,8 @@
import type { EnvScope, PublicEnv } from "@/src/types";
/**
* A scope as one select value: `general`, `workspace:3`, `computer:1`.
* A scope as one select value: `general`, `workspace:3`, `bot:1`,
* `computer:1`.
*/
export default function scopeValue({ scope }: { scope: Pick<EnvScope, "paradigm" | "id"> }) {
return scope.paradigm == "general" ? "general" : `${scope.paradigm}:${scope.id}`;
@@ -13,6 +14,7 @@ export default function scopeValue({ scope }: { scope: Pick<EnvScope, "paradigm"
export function envInScope({ env, scope }: { env: PublicEnv; scope: EnvScope }) {
if (env.paradigm != scope.paradigm) return false;
if (scope.paradigm == "workspace") return env.workspace_id == scope.id;
if (scope.paradigm == "bot") return env.bot_id == scope.id;
if (scope.paradigm == "computer") return env.computer_id == scope.id;
return true;
}