From 96735de48f8e26fe33995499a9a457ec811d0405 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 22 Oct 2026 08:30:40 +0100 Subject: [PATCH] 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. --- AGENTS.md | 8 +- db/schema.ts | 14 ++- db/types/db.ts | 8 +- src/bots-queue/run-bot-task.ts | 2 +- src/dict/env-dict.ts | 8 +- .../backend/db/envs/grab-all-envs.ts | 2 + .../backend/db/envs/grab-computer-envs.ts | 7 +- .../backend/db/envs/grab-envs-overview.ts | 101 +++++++++++------- .../backend/db/envs/grab-scoped-envs.ts | 58 ++++++++++ .../db/envs/resolve-scoped-envs.test.ts | 15 +++ .../backend/db/envs/resolve-scoped-envs.ts | 31 ++++-- src/functions/backend/db/envs/save-env.ts | 30 ++++-- .../backend/db/extensions/grab-run-envs.ts | 22 +++- src/harness/tools/bot/grab-bot-chat-tools.ts | 2 +- .../tools/computer/open-computer-tools.ts | 2 +- .../tools/computer/search-computers-tool.ts | 11 +- src/harness/tools/custom/build-custom-tool.ts | 9 +- src/harness/tools/custom/run-script-tool.ts | 15 ++- .../tools/extensions/open-bot-extensions.ts | 2 +- .../(functions)/post-env-api.ts | 8 +- .../envs}/env-form.tsx | 10 +- .../envs}/env-row-actions.tsx | 0 .../envs}/env-row.tsx | 22 ++-- .../envs}/envs-group.tsx | 50 +++++---- .../(partials)/envs/scoped-envs-group.tsx | 42 ++++++++ .../(sections)/bot-settings-section.tsx | 2 + .../settings/bots/[bot_id]/index.server.ts | 13 +++ .../(partials)/env-scope-select.tsx | 6 +- .../(partials)/known-envs-group.tsx | 6 +- .../environment-settings-section.tsx | 12 +-- .../environment/(utils)/scope-value.test.ts | 9 ++ .../environment/(utils)/scope-value.ts | 4 +- .../(sections)/workspace-overview.tsx | 2 + .../workspaces/[workspace_id]/index.server.ts | 13 +++ src/pages/api/admin/envs/delete-env.ts | 39 +++++-- src/pages/api/admin/envs/save-env.ts | 61 +++++++++-- src/schemas/env-schema.ts | 10 ++ src/types/index.ts | 30 +++++- 38 files changed, 551 insertions(+), 135 deletions(-) create mode 100644 src/functions/backend/db/envs/grab-scoped-envs.ts rename src/pages/admin/settings/{environment => }/(functions)/post-env-api.ts (69%) rename src/pages/admin/settings/{environment/(partials) => (partials)/envs}/env-form.tsx (93%) rename src/pages/admin/settings/{environment/(partials) => (partials)/envs}/env-row-actions.tsx (100%) rename src/pages/admin/settings/{environment/(partials) => (partials)/envs}/env-row.tsx (81%) rename src/pages/admin/settings/{environment/(partials) => (partials)/envs}/envs-group.tsx (53%) create mode 100644 src/pages/admin/settings/(partials)/envs/scoped-envs-group.tsx diff --git a/AGENTS.md b/AGENTS.md index b44718e..dd42b03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,11 +133,11 @@ Images are built by GitHub Actions on a published release (`.github/workflows/re ### Envs -Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it). +Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer, bot) with `workspace_id`/`computer_id`/`bot_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it). A bot's or a workspace's own envs are also edited on its settings page (`settings/(partials)/envs/scoped-envs-group.tsx`, fed by `grabScopedEnvs`, `bot_envs` / `workspace_envs` page props), super admins only, which is the same table and the same `save-env`/`delete-env` routes (`scoped: true` makes them answer with just that scope, `EnvsMutationResult`, so the page updates in place). - **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL`, the `VAPID_*` Web Push keys and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `DB_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are the container's own start-up settings. -- **Bot and computer envs** are any other key. `grabComputerEnvs` resolves one run's set (`resolveScopedEnvs`): computer beats workspace beats general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie. `openComputerTools` writes them to `~/.cobble/env` and `env.json` on the computer (owner only, rewritten every run) over the sandbox's REST API (`writeComputerEnv`); the sandbox's shells read no profile, so `withComputerEnv` makes `sandbox_execute_bash` source the file and `sandbox_execute_code` (Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed through `redactSecrets` (each secret value shown as `[KEY]`). -- **API:** `api/admin/envs/` (`save-env`, `delete-env`), super admin only, logged by key, each returning `grabEnvsOverview` (secret values left out; a secret is only replaced, never read back). +- **Bot and computer envs** are any other key. `grabComputerEnvs` resolves one run's set (`resolveScopedEnvs`): the bot's own beat the computer's, which beat the workspace's, which beat general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie. Every computer call site passes the run's `bot_id` (`openComputerTools`, `searchComputersTool`, `runScriptTool`, through `grabBotChatTools` / `run-bot-task`), and `grabRunEnvs` (`{{env.KEY}}` in an MCP server's or custom tool's templates) layers the bot's own over the workspace's over general. `openComputerTools` writes them to `~/.cobble/env` and `env.json` on the computer (owner only, rewritten every run) over the sandbox's REST API (`writeComputerEnv`); the sandbox's shells read no profile, so `withComputerEnv` makes `sandbox_execute_bash` source the file and `sandbox_execute_code` (Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed through `redactSecrets` (each secret value shown as `[KEY]`). +- **API:** `api/admin/envs/` (`save-env`, `delete-env`), super admin only, logged by key, each returning `grabEnvsOverview` (secret values left out; a secret is only replaced, never read back), or `grabScopedEnvs` when the request came from a bot's or workspace's settings page. ### PWA and notifications @@ -193,7 +193,7 @@ Every folder on the data volume, browsable read-only: a bot's own, a workspace's - **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`). - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/storage/`: `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file`, `generate_image`, `make_pdf` (`grabBotFileTools`, every run, see Bot files and folders). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`. - **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://:/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). -- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result. +- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (the bot's own beat the workspace's, which beat general; app settings never, no need to be set on computers); secret values are redacted from every result. - **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`. - **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `__` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete. - **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use. diff --git a/db/schema.ts b/db/schema.ts index 50aaa33..9698fb9 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -2467,7 +2467,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, { fieldName: "paradigm", - fieldDescription: `Where it applies: general, a workspace, or a computer. Computer beats workspace beats general`, + fieldDescription: `Where it applies: general, a workspace, a computer, or a single bot. A bot's own beats the computer, which beats the workspace, which beats general`, dataType: "TEXT", options: EnvParadigms.map((p) => p.value), defaultValue: "general", @@ -2496,6 +2496,18 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { foreignKeyName: "envs_computer_fk", }, }, + { + fieldName: "bot_id", + fieldDescription: `The bot, for paradigm bot. Goes with the bot`, + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "bots", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "envs_bot_fk", + }, + }, { fieldName: "secret", fieldDescription: `1 hides the value once saved (only replaced, never shown) and redacts it from everything bots read`, diff --git a/db/types/db.ts b/db/types/db.ts index 4ba3b53..a606773 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -1516,9 +1516,9 @@ export type BUN_SQLITE_COBBLE_ENVS = { */ description?: string; /** - * Where it applies: general, a workspace, or a computer. Computer beats workspace beats general + * Where it applies: general, a workspace, a computer, or a single bot. A bot's own beats the computer, which beats the workspace, which beats general */ - paradigm?: "general" | "workspace" | "computer" | ""; + paradigm?: "general" | "workspace" | "computer" | "bot" | ""; /** * The workspace, for paradigm workspace */ @@ -1527,6 +1527,10 @@ export type BUN_SQLITE_COBBLE_ENVS = { * The computer, for paradigm computer */ computer_id?: number | ""; + /** + * The bot, for paradigm bot. Goes with the bot + */ + bot_id?: number | ""; /** * 1 hides the value once saved (only replaced, never shown) and redacts it from everything bots read */ diff --git a/src/bots-queue/run-bot-task.ts b/src/bots-queue/run-bot-task.ts index de3ea72..3730b22 100644 --- a/src/bots-queue/run-bot-task.ts +++ b/src/bots-queue/run-bot-task.ts @@ -132,7 +132,7 @@ export default async function runBotTask({ : []), ...(computers.length ? [ - searchComputersTool({ computers, workspace_id }), + searchComputersTool({ bot_id: bot.id, computers, workspace_id }), askToTakeOverTool({ computers: take_over_computers, in_task: true, diff --git a/src/dict/env-dict.ts b/src/dict/env-dict.ts index 3519d05..bb293a8 100644 --- a/src/dict/env-dict.ts +++ b/src/dict/env-dict.ts @@ -1,8 +1,8 @@ import { HarnessProviderKinds } from "./harness-provider-dict"; /** - * Where an env applies. A computer's env beats a workspace's, which beats - * a general one. + * Where an env applies. A bot's own env beats the computer it runs on, + * which beats the workspace it works in, which beats a general one. */ export const EnvParadigms = [ { @@ -17,6 +17,10 @@ export const EnvParadigms = [ title: "Computer", value: "computer", }, + { + title: "Bot", + value: "bot", + }, ] as const; export type EnvParadigm = (typeof EnvParadigms)[number]["value"]; diff --git a/src/functions/backend/db/envs/grab-all-envs.ts b/src/functions/backend/db/envs/grab-all-envs.ts index 61e874f..753a602 100644 --- a/src/functions/backend/db/envs/grab-all-envs.ts +++ b/src/functions/backend/db/envs/grab-all-envs.ts @@ -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, })), diff --git a/src/functions/backend/db/envs/grab-computer-envs.ts b/src/functions/backend/db/envs/grab-computer-envs.ts index 7637e3e..ba30b10 100644 --- a/src/functions/backend/db/envs/grab-computer-envs.ts +++ b/src/functions/backend/db/envs/grab-computer-envs.ts @@ -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)] : [], }); diff --git a/src/functions/backend/db/envs/grab-envs-overview.ts b/src/functions/backend/db/envs/grab-envs-overview.ts index be71687..a59e8db 100644 --- a/src/functions/backend/db/envs/grab-envs-overview.ts +++ b/src/functions/backend/db/envs/grab-envs-overview.ts @@ -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 { - const [workspaces, computers, envs] = await Promise.all([ + const [workspaces, bots, computers, envs] = await Promise.all([ BunSQLite.sql({ sql: `SELECT id, name, archived FROM workspaces ORDER BY archived ASC, id ASC`, }), + BunSQLite.sql({ + sql: `SELECT id, name, kind FROM bots ORDER BY kind = 'coordinator' DESC, id ASC`, + }), BunSQLite.sql({ 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; +}) { + 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) + ); +} diff --git a/src/functions/backend/db/envs/grab-scoped-envs.ts b/src/functions/backend/db/envs/grab-scoped-envs.ts new file mode 100644 index 0000000..35c18b0 --- /dev/null +++ b/src/functions/backend/db/envs/grab-scoped-envs.ts @@ -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; + 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 { + 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, + }; +} diff --git a/src/functions/backend/db/envs/resolve-scoped-envs.test.ts b/src/functions/backend/db/envs/resolve-scoped-envs.test.ts index 786d971..27a6bd1 100644 --- a/src/functions/backend/db/envs/resolve-scoped-envs.test.ts +++ b/src/functions/backend/db/envs/resolve-scoped-envs.test.ts @@ -10,6 +10,7 @@ const env = (fields: Partial & { 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 }), diff --git a/src/functions/backend/db/envs/resolve-scoped-envs.ts b/src/functions/backend/db/envs/resolve-scoped-envs.ts index b7c1a05..60f1300 100644 --- a/src/functions/backend/db/envs/resolve-scoped-envs.ts +++ b/src/functions/backend/db/envs/resolve-scoped-envs.ts @@ -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) { diff --git a/src/functions/backend/db/envs/save-env.ts b/src/functions/backend/db/envs/save-env.ts index a0a2eed..7675d48 100644 --- a/src/functions/backend/db/envs/save-env.ts +++ b/src/functions/backend/db/envs/save-env.ts @@ -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({ 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 }; } diff --git a/src/functions/backend/db/extensions/grab-run-envs.ts b/src/functions/backend/db/extensions/grab-run-envs.ts index 1957ea3..8816c13 100644 --- a/src/functions/backend/db/extensions/grab-run-envs.ts +++ b/src/functions/backend/db/extensions/grab-run-envs.ts @@ -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 { +export default async function grabRunEnvs({ bot_id, workspace_id }: Params = {}): Promise { 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(); @@ -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); } } diff --git a/src/harness/tools/bot/grab-bot-chat-tools.ts b/src/harness/tools/bot/grab-bot-chat-tools.ts index 6bb1f40..8be171d 100644 --- a/src/harness/tools/bot/grab-bot-chat-tools.ts +++ b/src/harness/tools/bot/grab-bot-chat-tools.ts @@ -91,7 +91,7 @@ export default async function grabBotChatTools({ }), ...(computers.length ? [ - searchComputersTool({ computers, workspace_id }), + searchComputersTool({ bot_id, computers, workspace_id }), askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }), ] : []), diff --git a/src/harness/tools/computer/open-computer-tools.ts b/src/harness/tools/computer/open-computer-tools.ts index 33dfaa9..07734c2 100644 --- a/src/harness/tools/computer/open-computer-tools.ts +++ b/src/harness/tools/computer/open-computer-tools.ts @@ -63,7 +63,7 @@ export default async function openComputerTools({ throw new Error(computerNotReadyMessage(target)); } - const envs = await grabComputerEnvs({ computer_id, workspace_id }); + const envs = await grabComputerEnvs({ computer_id, bot_id, workspace_id }); const paths = await writeComputerEnv({ base_url: target.base_url, envs, signal }); const secrets = envs.filter((env) => env.secret); const redact = (text: string) => redactSecrets({ text, secrets }); diff --git a/src/harness/tools/computer/search-computers-tool.ts b/src/harness/tools/computer/search-computers-tool.ts index 219e92b..b3be02b 100644 --- a/src/harness/tools/computer/search-computers-tool.ts +++ b/src/harness/tools/computer/search-computers-tool.ts @@ -10,6 +10,10 @@ import type { HarnessTool } from "../../types"; import runComputerCommand from "./run-computer-command"; type Params = { + /** + * The bot the run is for: its own envs beat everything. + */ + bot_id?: number | string | null; /** * The workspace the run is in: its envs apply. */ @@ -31,6 +35,7 @@ type Args = { * so the bot can tell the user exactly which computer holds what. */ export default function searchComputersTool({ + bot_id, workspace_id, computers, }: Params): HarnessTool { @@ -59,7 +64,11 @@ export default function searchComputersTool({ timeout_ms: AppData.ComputerSearchTimeoutMs, signal, }), - grabComputerEnvs({ computer_id: computer.id || 0, workspace_id }), + grabComputerEnvs({ + computer_id: computer.id || 0, + bot_id, + workspace_id, + }), ]); const output = redactSecrets({ diff --git a/src/harness/tools/custom/build-custom-tool.ts b/src/harness/tools/custom/build-custom-tool.ts index 6fc7eb4..f4f68c0 100644 --- a/src/harness/tools/custom/build-custom-tool.ts +++ b/src/harness/tools/custom/build-custom-tool.ts @@ -52,7 +52,14 @@ export default function buildCustomTool({ case "http": return runHttpTool({ config: tool.config, args, envs, signal }); case "script": - return runScriptTool({ config: tool.config, args, computer_id: computer_id!, workspace_id, signal }); + return runScriptTool({ + config: tool.config, + args, + computer_id: computer_id!, + bot_id, + workspace_id, + signal, + }); case "prompt": return runPromptTool({ config: tool.config, args, envs, bot_id, workspace_id, signal }); } diff --git a/src/harness/tools/custom/run-script-tool.ts b/src/harness/tools/custom/run-script-tool.ts index 240f5d8..a68f17d 100644 --- a/src/harness/tools/custom/run-script-tool.ts +++ b/src/harness/tools/custom/run-script-tool.ts @@ -12,6 +12,10 @@ type Params = { config: CustomToolScriptConfig; args: Record; computer_id: number | string; + /** + * The bot the run is for: its own envs beat everything. + */ + bot_id?: number | string | null; /** * The workspace the run is in: its envs apply on the computer. */ @@ -26,13 +30,20 @@ type Params = { * so nothing in them needs quoting. Returns what it printed (stdout and * stderr), secrets redacted; a non-zero exit throws with it. */ -export default async function runScriptTool({ config, args, computer_id, workspace_id, signal }: Params) { +export default async function runScriptTool({ + config, + args, + computer_id, + bot_id, + workspace_id, + signal, +}: Params) { const target = await grabComputerMcpUrl({ computer_id }); if (!target) throw new Error(`Computer #${computer_id} has no container to run on.`); if (!target.ready) throw new Error(computerNotReadyMessage(target)); - const envs = await grabComputerEnvs({ computer_id, workspace_id }); + const envs = await grabComputerEnvs({ computer_id, bot_id, workspace_id }); const { shell_path } = await writeComputerEnv({ base_url: target.base_url, envs, signal }); const language = CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0]; diff --git a/src/harness/tools/extensions/open-bot-extensions.ts b/src/harness/tools/extensions/open-bot-extensions.ts index a4b783c..d7b3e35 100644 --- a/src/harness/tools/extensions/open-bot-extensions.ts +++ b/src/harness/tools/extensions/open-bot-extensions.ts @@ -51,7 +51,7 @@ export default async function openBotExtensions({ if (!servers.length && !tools.length) return { tools: [], note: "", close: async () => {} }; - const envs = await grabRunEnvs({ workspace_id }); + const envs = await grabRunEnvs({ bot_id, workspace_id }); const secrets = envs.filter((env) => env.secret); const redact = (text: string) => redactSecrets({ text, secrets }); const names = new Set(taken); diff --git a/src/pages/admin/settings/environment/(functions)/post-env-api.ts b/src/pages/admin/settings/(functions)/post-env-api.ts similarity index 69% rename from src/pages/admin/settings/environment/(functions)/post-env-api.ts rename to src/pages/admin/settings/(functions)/post-env-api.ts index ff4346d..909c1cf 100644 --- a/src/pages/admin/settings/environment/(functions)/post-env-api.ts +++ b/src/pages/admin/settings/(functions)/post-env-api.ts @@ -1,6 +1,6 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import type { APIResponseObject } from "@moduletrace/bunext/types"; -import type { EnvsOverviewPayload } from "@/src/types"; +import type { EnvsMutationResult } from "@/src/types"; type Params = { /** @@ -12,10 +12,12 @@ type Params = { /** * Posts to an env API route. Every route answers with the fresh page data - * in `singleRes`, so the page updates in place. + * in `singleRes`: the whole Environment overview, or just the one bot's or + * workspace's envs (the settings pages, `body.scoped`), so the page updates + * in place. */ export default async function postEnvApi({ path, body }: Params) { - return await fetchApi, APIResponseObject>( + return await fetchApi, APIResponseObject>( `/api/admin/envs/${path}`, { method: "POST", body }, ); diff --git a/src/pages/admin/settings/environment/(partials)/env-form.tsx b/src/pages/admin/settings/(partials)/envs/env-form.tsx similarity index 93% rename from src/pages/admin/settings/environment/(partials)/env-form.tsx rename to src/pages/admin/settings/(partials)/envs/env-form.tsx index 4b2e290..8d2076e 100644 --- a/src/pages/admin/settings/environment/(partials)/env-form.tsx +++ b/src/pages/admin/settings/(partials)/envs/env-form.tsx @@ -5,9 +5,9 @@ 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"; +import type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types"; +import postEnvApi from "../../(functions)/post-env-api"; +import SettingsStatus from "../settings-status"; type Props = { scope: EnvScope; @@ -19,7 +19,7 @@ type Props = { * Sets this app setting: its key, description and flags are fixed. */ known?: KnownEnvStatus; - onSaved: (overview: EnvsOverviewPayload, msg: string) => void; + onSaved: (data: EnvsMutationResult, msg: string) => void; onCancel: () => void; }; @@ -57,6 +57,8 @@ export default function EnvForm({ scope, env, known, onSaved, onCancel }: Props) paradigm: scope.paradigm, workspace_id: scope.paradigm == "workspace" ? scope.id : null, computer_id: scope.paradigm == "computer" ? scope.id : null, + bot_id: scope.paradigm == "bot" ? scope.id : null, + scoped: scope.paradigm == "bot" || scope.paradigm == "workspace", secret, expose_to_computers: expose, }, diff --git a/src/pages/admin/settings/environment/(partials)/env-row-actions.tsx b/src/pages/admin/settings/(partials)/envs/env-row-actions.tsx similarity index 100% rename from src/pages/admin/settings/environment/(partials)/env-row-actions.tsx rename to src/pages/admin/settings/(partials)/envs/env-row-actions.tsx diff --git a/src/pages/admin/settings/environment/(partials)/env-row.tsx b/src/pages/admin/settings/(partials)/envs/env-row.tsx similarity index 81% rename from src/pages/admin/settings/environment/(partials)/env-row.tsx rename to src/pages/admin/settings/(partials)/envs/env-row.tsx index 1576110..88b7f37 100644 --- a/src/pages/admin/settings/environment/(partials)/env-row.tsx +++ b/src/pages/admin/settings/(partials)/envs/env-row.tsx @@ -2,8 +2,8 @@ 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 type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types"; +import postEnvApi from "../../(functions)/post-env-api"; import EnvForm from "./env-form"; import EnvRowActions from "./env-row-actions"; @@ -14,7 +14,12 @@ type Props = { */ env?: PublicEnv; known?: KnownEnvStatus; - onChanged: (overview: EnvsOverviewPayload, msg: string) => void; + /** + * The bot or workspace's own list (its settings page) rather than the + * Environment page's overview. + */ + scoped?: boolean; + onChanged: (data: EnvsMutationResult, msg: string) => void; onError: (msg: string) => void; }; @@ -22,7 +27,7 @@ type Props = { * 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) { +export default function EnvRow({ scope, env, known, scoped, onChanged, onError }: Props) { const [editing, setEditing] = useState(false); const [busy, setBusy] = useState(false); const key = env?.key || known?.key || ""; @@ -32,7 +37,10 @@ export default function EnvRow({ scope, env, known, onChanged, onError }: Props) 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 } }); + const res = await postEnvApi({ + path: "delete-env", + body: { env_id: env.id, scoped }, + }); setBusy(false); if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed."); @@ -46,9 +54,9 @@ export default function EnvRow({ scope, env, known, onChanged, onError }: Props) scope={scope} env={env} known={known} - onSaved={(overview, msg) => { + onSaved={(data, msg) => { setEditing(false); - onChanged(overview, msg); + onChanged(data, msg); }} onCancel={() => setEditing(false)} /> diff --git a/src/pages/admin/settings/environment/(partials)/envs-group.tsx b/src/pages/admin/settings/(partials)/envs/envs-group.tsx similarity index 53% rename from src/pages/admin/settings/environment/(partials)/envs-group.tsx rename to src/pages/admin/settings/(partials)/envs/envs-group.tsx index a989091..58948eb 100644 --- a/src/pages/admin/settings/environment/(partials)/envs-group.tsx +++ b/src/pages/admin/settings/(partials)/envs/envs-group.tsx @@ -1,11 +1,11 @@ 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 type { EnvScope, EnvsMutationResult, PublicEnv } from "@/src/types"; +import SettingsGroup from "../settings-group"; +import SettingsList from "../settings-list"; +import SettingsStatus from "../settings-status"; import EnvForm from "./env-form"; import EnvRow from "./env-row"; @@ -15,46 +15,58 @@ type Props = { * The scope's own envs (app settings left out). */ envs: PublicEnv[]; - onChanged: (overview: EnvsOverviewPayload, msg: string) => void; + /** + * Set when the list is a bot's or workspace's own (its settings page) + * rather than the Environment page's overview. + */ + scoped?: boolean; + /** + * The heading, when it isn't plain "Envs". + */ + title?: string; + status?: { + error?: boolean; + success?: boolean; + msg?: string; + }; + onChanged: (data: EnvsMutationResult, msg: string) => void; onError: (msg: string) => void; }; const Descriptions: Record = { - 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.", + general: "Set everywhere. A workspace, a bot or a computer can set the same key to override it.", + workspace: "For every bot working here. Beats general, loses to the bot's own.", + computer: "For this computer only. Beats a workspace, loses to the bot's own.", + bot: "For this bot only, everywhere it works. Beats the workspace, the computer and general.", }; /** * The envs bots and computers use, in one scope, with an add form. */ -export default function EnvsGroup({ scope, envs, onChanged, onError }: Props) { +export default function EnvsGroup({ scope, envs, scoped, title, status, onChanged, onError }: Props) { const [adding, setAdding] = useState(false); return ( - - + + {envs.map((env) => ( ))} - {envs.length ? null : ( - - Nothing set here yet. - - )} - + + {adding ? ( { + onSaved={(data, msg) => { setAdding(false); - onChanged(overview, msg); + onChanged(data, msg); }} onCancel={() => setAdding(false)} /> diff --git a/src/pages/admin/settings/(partials)/envs/scoped-envs-group.tsx b/src/pages/admin/settings/(partials)/envs/scoped-envs-group.tsx new file mode 100644 index 0000000..7562b2c --- /dev/null +++ b/src/pages/admin/settings/(partials)/envs/scoped-envs-group.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { EnvsMutationResult, ScopedEnvsPayload } from "@/src/types"; +import EnvsGroup from "./envs-group"; + +type Props = { + /** + * A bot's own envs, or a workspace's. `null` when the viewer can't + * manage them, so the group isn't rendered at all. + */ + initial?: ScopedEnvsPayload | null; +}; + +/** + * The envs set on one bot or one workspace, on its settings page. Super + * admins only, like the Environment page. + */ +export default function ScopedEnvsGroup({ initial }: Props) { + const [data, setData] = useState(initial || null); + const [status, setStatus] = useState<{ + error?: boolean; + success?: boolean; + msg?: string; + }>(); + + if (!data?.can_manage) return null; + + const changed = (result: EnvsMutationResult, msg: string) => { + if (result.scoped) setData(result.scoped); + setStatus({ success: true, msg }); + }; + + return ( + setStatus({ error: true, msg })} + /> + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx index a4b7c8e..b5681a7 100644 --- a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx +++ b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx @@ -3,6 +3,7 @@ import { AppContext } from "@/src/pages/__root"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BotWithLastMessage } from "@/src/types"; import BotHarnessForm from "../../../(partials)/bot-harness-form"; +import ScopedEnvsGroup from "../../../(partials)/envs/scoped-envs-group"; import ExtensionsGroup from "../../../(partials)/extensions-group"; import SettingsSectionHeading from "../../../(partials)/settings-section-heading"; import SettingsShell from "../../../(partials)/settings-shell"; @@ -77,6 +78,7 @@ export default function BotSettingsSection() { description="What it can use besides its own tools, everywhere it works. A workspace's own come on top while it works there." initial={pageProps?.bot_extensions} /> + {is_coordinator ? null : } ); diff --git a/src/pages/admin/settings/bots/[bot_id]/index.server.ts b/src/pages/admin/settings/bots/[bot_id]/index.server.ts index 2cb3af8..ee1d8d8 100644 --- a/src/pages/admin/settings/bots/[bot_id]/index.server.ts +++ b/src/pages/admin/settings/bots/[bot_id]/index.server.ts @@ -4,6 +4,9 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots"; import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot"; import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers"; import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers"; +import grabScopedEnvs from "@/src/functions/backend/db/envs/grab-scoped-envs"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import grabBotExtensionsOverview from "@/src/functions/backend/db/extensions/grab-bot-extensions-overview"; import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview"; import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview"; @@ -40,6 +43,7 @@ export const server: BunextPageServerFn = async (params) => { bot_computer_rows, bot_harness_overview, bot_extensions, + bot_envs, ] = await Promise.all([ grabBots({ user_id }), grabUserWorkspaces({ user_id }), @@ -51,6 +55,14 @@ export const server: BunextPageServerFn = async (params) => { : grabBotHarnessOverview({ bot_id: bot.id }) ).catch(() => null), grabBotExtensionsOverview({ user_id, bot_id: bot.id }), + grabScopedEnvs({ + paradigm: "bot", + owner_id: bot.id, + user_id, + can_manage: isPassThroughUser({ + user_types: (await userAuth({ req: params.req })).user_types, + }), + }), ]); const bot_computers = bot_computer_rows @@ -72,6 +84,7 @@ export const server: BunextPageServerFn = async (params) => { computers: all_computers, bot_harness_overview, bot_extensions, + bot_envs, }, }; }; diff --git a/src/pages/admin/settings/environment/(partials)/env-scope-select.tsx b/src/pages/admin/settings/environment/(partials)/env-scope-select.tsx index cab2a49..897cdfe 100644 --- a/src/pages/admin/settings/environment/(partials)/env-scope-select.tsx +++ b/src/pages/admin/settings/environment/(partials)/env-scope-select.tsx @@ -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 (