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
+4 -4
View File
@@ -133,11 +133,11 @@ Images are built by GitHub Actions on a published release (`.github/workflows/re
### Envs ### 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. - **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]`). - **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). - **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 ### 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`). - **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*`. - **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://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **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://<hostname>:<port>/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`. - **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/<id>` (`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 `<slug>__<tool>` (`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. - **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/<id>` (`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 `<slug>__<tool>` (`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. - **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.
+13 -1
View File
@@ -2467,7 +2467,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
{ {
fieldName: "paradigm", 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", dataType: "TEXT",
options: EnvParadigms.map((p) => p.value), options: EnvParadigms.map((p) => p.value),
defaultValue: "general", defaultValue: "general",
@@ -2496,6 +2496,18 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
foreignKeyName: "envs_computer_fk", 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", fieldName: "secret",
fieldDescription: `1 hides the value once saved (only replaced, never shown) and redacts it from everything bots read`, fieldDescription: `1 hides the value once saved (only replaced, never shown) and redacts it from everything bots read`,
+6 -2
View File
@@ -1516,9 +1516,9 @@ export type BUN_SQLITE_COBBLE_ENVS = {
*/ */
description?: string; 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 * The workspace, for paradigm workspace
*/ */
@@ -1527,6 +1527,10 @@ export type BUN_SQLITE_COBBLE_ENVS = {
* The computer, for paradigm computer * The computer, for paradigm computer
*/ */
computer_id?: number | ""; 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 * 1 hides the value once saved (only replaced, never shown) and redacts it from everything bots read
*/ */
+1 -1
View File
@@ -132,7 +132,7 @@ export default async function runBotTask({
: []), : []),
...(computers.length ...(computers.length
? [ ? [
searchComputersTool({ computers, workspace_id }), searchComputersTool({ bot_id: bot.id, computers, workspace_id }),
askToTakeOverTool({ askToTakeOverTool({
computers: take_over_computers, computers: take_over_computers,
in_task: true, in_task: true,
+6 -2
View File
@@ -1,8 +1,8 @@
import { HarnessProviderKinds } from "./harness-provider-dict"; import { HarnessProviderKinds } from "./harness-provider-dict";
/** /**
* Where an env applies. A computer's env beats a workspace's, which beats * Where an env applies. A bot's own env beats the computer it runs on,
* a general one. * which beats the workspace it works in, which beats a general one.
*/ */
export const EnvParadigms = [ export const EnvParadigms = [
{ {
@@ -17,6 +17,10 @@ export const EnvParadigms = [
title: "Computer", title: "Computer",
value: "computer", value: "computer",
}, },
{
title: "Bot",
value: "bot",
},
] as const; ] as const;
export type EnvParadigm = (typeof EnvParadigms)[number]["value"]; export type EnvParadigm = (typeof EnvParadigms)[number]["value"];
@@ -11,6 +11,7 @@ export type DecryptedEnv = {
paradigm: EnvParadigm; paradigm: EnvParadigm;
workspace_id: number | null; workspace_id: number | null;
computer_id: number | null; computer_id: number | null;
bot_id: number | null;
secret: boolean; secret: boolean;
expose_to_computers: boolean; expose_to_computers: boolean;
}; };
@@ -52,6 +53,7 @@ async function load() {
paradigm: (row.paradigm || "general") as EnvParadigm, paradigm: (row.paradigm || "general") as EnvParadigm,
workspace_id: typeof row.workspace_id == "number" ? row.workspace_id : null, workspace_id: typeof row.workspace_id == "number" ? row.workspace_id : null,
computer_id: typeof row.computer_id == "number" ? row.computer_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, secret: row.secret != 0,
expose_to_computers: row.expose_to_computers != 0, expose_to_computers: row.expose_to_computers != 0,
})), })),
@@ -4,6 +4,10 @@ import resolveScopedEnvs from "./resolve-scoped-envs";
type Params = { type Params = {
computer_id: number | string; 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 * 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 * 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`). * 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 const linked = workspace_id
? ( ? (
await BunSQLite.sql<{ id: number }>({ await BunSQLite.sql<{ id: number }>({
@@ -29,6 +33,7 @@ export default async function grabComputerEnvs({ computer_id, workspace_id }: Pa
return resolveScopedEnvs({ return resolveScopedEnvs({
envs: await grabAllEnvs(), envs: await grabAllEnvs(),
bot_id,
computer_id: Number(computer_id), computer_id: Number(computer_id),
workspace_ids: linked?.id ? [Number(linked.id)] : [], workspace_ids: linked?.id ? [Number(linked.id)] : [],
}); });
@@ -1,73 +1,64 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_WORKSPACES, BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db"; } from "@/db/types/db";
import { EnvDict } from "@/src/dict/env-dict"; import { EnvDict } from "@/src/dict/env-dict";
import type { EnvsOverviewPayload } from "@/src/types"; import type { EnvsOverviewPayload } from "@/src/types";
import grabAllEnvs from "./grab-all-envs"; import grabAllEnvs, { type DecryptedEnv } from "./grab-all-envs";
type Params = { type Params = {
/** /**
* The super admin looking: workspaces and computers belong to the * The super admin looking: workspaces, computers and bots belong to
* install, so they see every one. * the install, so they see every one.
*/ */
user_id: number; user_id: number;
}; };
/** /**
* The Environment settings page: where envs can be set (general, every * 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. * and the app settings with where each comes from.
*/ */
export default async function grabEnvsOverview(_params: Params): Promise<EnvsOverviewPayload> { 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>({ BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, name, archived FROM workspaces ORDER BY archived ASC, id ASC`, 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>({ BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT id, name, kind FROM computers ORDER BY kind = 'main' DESC, id ASC`, sql: `SELECT id, name, kind FROM computers ORDER BY kind = 'main' DESC, id ASC`,
}), }),
grabAllEnvs(), 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 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 { return {
scopes: [ scopes: [
{ paradigm: "general", id: null, title: "General" }, { paradigm: "general", id: null, title: "General" },
...[
...(workspaces.payload || []).map((workspace) => ({ ...(workspaces.payload || []).map((workspace) => ({
paradigm: "workspace" as const, paradigm: "workspace" as const,
id: Number(workspace.id), id: Number(workspace.id),
title: `Workspace: ${workspace.name}${workspace.archived ? " (archived)" : ""}`, 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) => ({ ...(computers.payload || []).map((computer) => ({
paradigm: "computer" as const, paradigm: "computer" as const,
id: Number(computer.id), id: Number(computer.id),
title: `Computer: ${computer.name || computer.kind}`, title: `Computer: ${computer.name || computer.kind}`,
})), })),
].sort(compareScopes),
], ],
envs: visible.map((env) => ({ envs: envs.map((env) => publicEnv({ env, known_keys })),
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),
})),
known: EnvDict.map((env) => ({ known: EnvDict.map((env) => ({
...env, ...env,
saved: envs.some((saved) => saved.paradigm == "general" && saved.key == env.key), 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", paradigm: "general",
workspace_id: null, workspace_id: null,
computer_id: null, computer_id: null,
bot_id: null,
secret: true, secret: true,
expose_to_computers: true, expose_to_computers: true,
...fields, ...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", () => { test("leaves out unexposed envs and app settings", () => {
const envs = [ const envs = [
env({ key: "HIDDEN", value: "x", expose_to_computers: false }), env({ key: "HIDDEN", value: "x", expose_to_computers: false }),
@@ -3,22 +3,28 @@ import type { DecryptedEnv } from "./grab-all-envs";
type Params = { type Params = {
envs: DecryptedEnv[]; 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). * 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 * The envs that reach one run, one per key: the bot's own beat the
* workspaces' (the first listed workspace wins a tie), which beat general * computer's, which beat its workspaces' (the first listed workspace wins
* ones. Only envs exposed to computers; app settings never are. * a tie), which beat general ones. Only envs exposed to computers; app
* settings never are.
*/ */
export default function resolveScopedEnvs({ export default function resolveScopedEnvs({
envs, envs,
bot_id,
computer_id, computer_id,
workspace_ids, workspace_ids = [],
}: Params): DecryptedEnv[] { }: Params): DecryptedEnv[] {
const app_keys = new Set(EnvDict.map((env) => env.key)); const app_keys = new Set(EnvDict.map((env) => env.key));
const exposed = envs.filter( const exposed = envs.filter(
@@ -29,9 +35,18 @@ export default function resolveScopedEnvs({
const layers = [ const layers = [
exposed.filter((env) => env.paradigm == "general"), exposed.filter((env) => env.paradigm == "general"),
...[...workspace_ids].reverse().map((id) => ...[...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) { for (const layer of layers) {
+23 -7
View File
@@ -28,6 +28,7 @@ type Params = {
paradigm: EnvParadigm; paradigm: EnvParadigm;
workspace_id?: number | null; workspace_id?: number | null;
computer_id?: number | null; computer_id?: number | null;
bot_id?: number | null;
secret: boolean; secret: boolean;
expose_to_computers: boolean; expose_to_computers: boolean;
}; };
@@ -47,6 +48,7 @@ export default async function saveEnv({
paradigm, paradigm,
workspace_id, workspace_id,
computer_id, computer_id,
bot_id,
secret, secret,
expose_to_computers, expose_to_computers,
}: Params) { }: 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.`); 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 = ( const duplicate = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({ await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
sql: `SELECT id FROM envs WHERE key = ? AND paradigm = ? sql: `SELECT id FROM envs WHERE key = ? AND paradigm = ?
AND COALESCE(workspace_id, 0) = ? AND COALESCE(computer_id, 0) = ? AND COALESCE(workspace_id, 0) = ? AND COALESCE(computer_id, 0) = ?
AND COALESCE(bot_id, 0) = ?
AND id != ? LIMIT 1`, 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]; ).payload?.[0];
@@ -87,6 +97,7 @@ export default async function saveEnv({
paradigm, paradigm,
workspace_id: scope.workspace_id, workspace_id: scope.workspace_id,
computer_id: scope.computer_id, computer_id: scope.computer_id,
bot_id: scope.bot_id,
secret: (known ? known.secret : secret) ? 1 : 0, secret: (known ? known.secret : secret) ? 1 : 0,
expose_to_computers: !known && expose_to_computers ? 1 : 0, expose_to_computers: !known && expose_to_computers ? 1 : 0,
}; };
@@ -140,18 +151,21 @@ async function resolveScope({
paradigm, paradigm,
workspace_id, workspace_id,
computer_id, computer_id,
bot_id,
}: { }: {
user_id: number; user_id: number;
paradigm: EnvParadigm; paradigm: EnvParadigm;
workspace_id?: number | null; workspace_id?: number | null;
computer_id?: number | null; computer_id?: number | null;
bot_id?: number | null;
}) { }) {
if (paradigm == "general") { 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 table =
const id = paradigm == "workspace" ? workspace_id : computer_id; paradigm == "workspace" ? "workspaces" : paradigm == "bot" ? "bots" : "computers";
const id = paradigm == "workspace" ? workspace_id : paradigm == "bot" ? bot_id : computer_id;
const owned = id const owned = id
? ( ? (
@@ -167,6 +181,8 @@ async function resolveScope({
} }
return paradigm == "workspace" return paradigm == "workspace"
? { workspace_id: Number(id), computer_id: null } ? { workspace_id: Number(id), computer_id: null, bot_id: null }
: { workspace_id: null, computer_id: Number(id) }; : 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"; import grabAllEnvs, { type DecryptedEnv } from "../envs/grab-all-envs";
type Params = { 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. * 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 * 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 * on one run, one per key: the bot's own beat the workspace's, which beat
* (`EnvDict`) never are. Unlike computers' envs, they don't have to be set * general ones. App settings (`EnvDict`) never are. Unlike computers' envs,
* on computers: the app fills them in itself. * 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 app_keys = new Set(EnvDict.map((env) => env.key));
const envs = (await grabAllEnvs()).filter((env) => !app_keys.has(env.key)); const envs = (await grabAllEnvs()).filter((env) => !app_keys.has(env.key));
const resolved = new Map<string, DecryptedEnv>(); 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); for (const env of envs.filter((e) => e.paradigm == "general")) resolved.set(env.key, env);
if (workspace_id) { 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); resolved.set(env.key, env);
} }
} }
+1 -1
View File
@@ -91,7 +91,7 @@ export default async function grabBotChatTools({
}), }),
...(computers.length ...(computers.length
? [ ? [
searchComputersTool({ computers, workspace_id }), searchComputersTool({ bot_id, computers, workspace_id }),
askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }), askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }),
] ]
: []), : []),
@@ -63,7 +63,7 @@ export default async function openComputerTools({
throw new Error(computerNotReadyMessage(target)); 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 paths = await writeComputerEnv({ base_url: target.base_url, envs, signal });
const secrets = envs.filter((env) => env.secret); const secrets = envs.filter((env) => env.secret);
const redact = (text: string) => redactSecrets({ text, secrets }); const redact = (text: string) => redactSecrets({ text, secrets });
@@ -10,6 +10,10 @@ import type { HarnessTool } from "../../types";
import runComputerCommand from "./run-computer-command"; import runComputerCommand from "./run-computer-command";
type Params = { 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. * 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. * so the bot can tell the user exactly which computer holds what.
*/ */
export default function searchComputersTool({ export default function searchComputersTool({
bot_id,
workspace_id, workspace_id,
computers, computers,
}: Params): HarnessTool<Args> { }: Params): HarnessTool<Args> {
@@ -59,7 +64,11 @@ export default function searchComputersTool({
timeout_ms: AppData.ComputerSearchTimeoutMs, timeout_ms: AppData.ComputerSearchTimeoutMs,
signal, signal,
}), }),
grabComputerEnvs({ computer_id: computer.id || 0, workspace_id }), grabComputerEnvs({
computer_id: computer.id || 0,
bot_id,
workspace_id,
}),
]); ]);
const output = redactSecrets({ const output = redactSecrets({
@@ -52,7 +52,14 @@ export default function buildCustomTool({
case "http": case "http":
return runHttpTool({ config: tool.config, args, envs, signal }); return runHttpTool({ config: tool.config, args, envs, signal });
case "script": 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": case "prompt":
return runPromptTool({ config: tool.config, args, envs, bot_id, workspace_id, signal }); return runPromptTool({ config: tool.config, args, envs, bot_id, workspace_id, signal });
} }
+13 -2
View File
@@ -12,6 +12,10 @@ type Params = {
config: CustomToolScriptConfig; config: CustomToolScriptConfig;
args: Record<string, unknown>; args: Record<string, unknown>;
computer_id: number | string; 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. * 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 * so nothing in them needs quoting. Returns what it printed (stdout and
* stderr), secrets redacted; a non-zero exit throws with it. * 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 }); const target = await grabComputerMcpUrl({ computer_id });
if (!target) throw new Error(`Computer #${computer_id} has no container to run on.`); if (!target) throw new Error(`Computer #${computer_id} has no container to run on.`);
if (!target.ready) throw new Error(computerNotReadyMessage(target)); 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 { shell_path } = await writeComputerEnv({ base_url: target.base_url, envs, signal });
const language = const language =
CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0]; CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0];
@@ -51,7 +51,7 @@ export default async function openBotExtensions({
if (!servers.length && !tools.length) return { tools: [], note: "", close: async () => {} }; 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 secrets = envs.filter((env) => env.secret);
const redact = (text: string) => redactSecrets({ text, secrets }); const redact = (text: string) => redactSecrets({ text, secrets });
const names = new Set(taken); const names = new Set(taken);
@@ -1,6 +1,6 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { EnvsOverviewPayload } from "@/src/types"; import type { EnvsMutationResult } from "@/src/types";
type Params = { type Params = {
/** /**
@@ -12,10 +12,12 @@ type Params = {
/** /**
* Posts to an env API route. Every route answers with the fresh page data * 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) { export default async function postEnvApi({ path, body }: Params) {
return await fetchApi<Record<string, unknown>, APIResponseObject<EnvsOverviewPayload>>( return await fetchApi<Record<string, unknown>, APIResponseObject<EnvsMutationResult>>(
`/api/admin/envs/${path}`, `/api/admin/envs/${path}`,
{ method: "POST", body }, { method: "POST", body },
); );
@@ -5,9 +5,9 @@ import Checkbox from "@moduletrace/twui/form/Checkbox";
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types"; import type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types";
import SettingsStatus from "../../(partials)/settings-status"; import postEnvApi from "../../(functions)/post-env-api";
import postEnvApi from "../(functions)/post-env-api"; import SettingsStatus from "../settings-status";
type Props = { type Props = {
scope: EnvScope; scope: EnvScope;
@@ -19,7 +19,7 @@ type Props = {
* Sets this app setting: its key, description and flags are fixed. * Sets this app setting: its key, description and flags are fixed.
*/ */
known?: KnownEnvStatus; known?: KnownEnvStatus;
onSaved: (overview: EnvsOverviewPayload, msg: string) => void; onSaved: (data: EnvsMutationResult, msg: string) => void;
onCancel: () => void; onCancel: () => void;
}; };
@@ -57,6 +57,8 @@ export default function EnvForm({ scope, env, known, onSaved, onCancel }: Props)
paradigm: scope.paradigm, paradigm: scope.paradigm,
workspace_id: scope.paradigm == "workspace" ? scope.id : null, workspace_id: scope.paradigm == "workspace" ? scope.id : null,
computer_id: scope.paradigm == "computer" ? 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, secret,
expose_to_computers: expose, expose_to_computers: expose,
}, },
@@ -2,8 +2,8 @@ import { useState } from "react";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types"; import type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types";
import postEnvApi from "../(functions)/post-env-api"; import postEnvApi from "../../(functions)/post-env-api";
import EnvForm from "./env-form"; import EnvForm from "./env-form";
import EnvRowActions from "./env-row-actions"; import EnvRowActions from "./env-row-actions";
@@ -14,7 +14,12 @@ type Props = {
*/ */
env?: PublicEnv; env?: PublicEnv;
known?: KnownEnvStatus; 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; onError: (msg: string) => void;
}; };
@@ -22,7 +27,7 @@ type Props = {
* One env: its key, description and value (or "secret"), with Edit and * One env: its key, description and value (or "secret"), with Edit and
* Remove. Edit opens the form in place. * 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 [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const key = env?.key || known?.key || ""; 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; if (!window.confirm(`Remove ${key}? Computers drop it on their next run.`)) return;
setBusy(true); 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); setBusy(false);
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed."); 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} scope={scope}
env={env} env={env}
known={known} known={known}
onSaved={(overview, msg) => { onSaved={(data, msg) => {
setEditing(false); setEditing(false);
onChanged(overview, msg); onChanged(data, msg);
}} }}
onCancel={() => setEditing(false)} onCancel={() => setEditing(false)}
/> />
@@ -1,11 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row"; 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 { Plus } from "lucide-react";
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types"; import type { EnvScope, EnvsMutationResult, PublicEnv } from "@/src/types";
import SettingsGroup from "../../(partials)/settings-group"; import SettingsGroup from "../settings-group";
import SettingsList from "../settings-list";
import SettingsStatus from "../settings-status";
import EnvForm from "./env-form"; import EnvForm from "./env-form";
import EnvRow from "./env-row"; import EnvRow from "./env-row";
@@ -15,46 +15,58 @@ type Props = {
* The scope's own envs (app settings left out). * The scope's own envs (app settings left out).
*/ */
envs: PublicEnv[]; 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; onError: (msg: string) => void;
}; };
const Descriptions: Record<EnvScope["paradigm"], string> = { const Descriptions: Record<EnvScope["paradigm"], string> = {
general: "Set on every computer. A workspace or computer can set the same key to override it.", general: "Set everywhere. A workspace, a bot or a 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.", workspace: "For every bot working here. Beats general, loses to the bot's own.",
computer: "Set on this computer only. Beats workspace and general.", 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. * 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); const [adding, setAdding] = useState(false);
return ( return (
<SettingsGroup title="Envs" description={Descriptions[scope.paradigm]}> <SettingsGroup title={title || "Envs"} description={Descriptions[scope.paradigm]}>
<Stack className="w-full gap-0! items-stretch"> <SettingsList is_empty={!envs.length} empty="Nothing set here yet.">
{envs.map((env) => ( {envs.map((env) => (
<EnvRow <EnvRow
key={env.id} key={env.id}
scope={scope} scope={scope}
env={env} env={env}
scoped={scoped}
onChanged={onChanged} onChanged={onChanged}
onError={onError} onError={onError}
/> />
))} ))}
{envs.length ? null : ( </SettingsList>
<Span className="text-[13px] leading-5 text-ink-secondary py-3"> <SettingsStatus status={status} />
Nothing set here yet.
</Span>
)}
</Stack>
{adding ? ( {adding ? (
<EnvForm <EnvForm
scope={scope} scope={scope}
onSaved={(overview, msg) => { onSaved={(data, msg) => {
setAdding(false); setAdding(false);
onChanged(overview, msg); onChanged(data, msg);
}} }}
onCancel={() => setAdding(false)} onCancel={() => setAdding(false)}
/> />
@@ -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<ScopedEnvsPayload | null>(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 (
<EnvsGroup
scope={data.scope}
envs={data.envs}
scoped
status={status}
onChanged={changed}
onError={(msg) => setStatus({ error: true, msg })}
/>
);
}
@@ -3,6 +3,7 @@ import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types"; import type { BotWithLastMessage } from "@/src/types";
import BotHarnessForm from "../../../(partials)/bot-harness-form"; import BotHarnessForm from "../../../(partials)/bot-harness-form";
import ScopedEnvsGroup from "../../../(partials)/envs/scoped-envs-group";
import ExtensionsGroup from "../../../(partials)/extensions-group"; import ExtensionsGroup from "../../../(partials)/extensions-group";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading"; import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell"; 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." 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} initial={pageProps?.bot_extensions}
/> />
<ScopedEnvsGroup initial={pageProps?.bot_envs} />
{is_coordinator ? null : <BotDeleteGroup bot={bot} />} {is_coordinator ? null : <BotDeleteGroup bot={bot} />}
</SettingsShell> </SettingsShell>
); );
@@ -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 grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers"; import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-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 grabBotExtensionsOverview from "@/src/functions/backend/db/extensions/grab-bot-extensions-overview";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview"; import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview"; import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
@@ -40,6 +43,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot_computer_rows, bot_computer_rows,
bot_harness_overview, bot_harness_overview,
bot_extensions, bot_extensions,
bot_envs,
] = await Promise.all([ ] = await Promise.all([
grabBots({ user_id }), grabBots({ user_id }),
grabUserWorkspaces({ user_id }), grabUserWorkspaces({ user_id }),
@@ -51,6 +55,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
: grabBotHarnessOverview({ bot_id: bot.id }) : grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null), ).catch(() => null),
grabBotExtensionsOverview({ user_id, bot_id: bot.id }), 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 const bot_computers = bot_computer_rows
@@ -72,6 +84,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
computers: all_computers, computers: all_computers,
bot_harness_overview, bot_harness_overview,
bot_extensions, bot_extensions,
bot_envs,
}, },
}; };
}; };
@@ -12,14 +12,14 @@ type Props = {
}; };
/** /**
* Where the envs below apply: general, a workspace or a computer. Also * Where the envs below apply: general, a workspace, a bot or a computer.
* shows the last change's status. * Also shows the last change's status.
*/ */
export default function EnvScopeSelect({ scopes, value, onChange, status }: Props) { export default function EnvScopeSelect({ scopes, value, onChange, status }: Props) {
return ( return (
<SettingsGroup <SettingsGroup
title="Where" 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 <Select
id="env-scope" id="env-scope"
@@ -1,12 +1,12 @@
import Stack from "@moduletrace/twui/layout/Stack"; 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 SettingsGroup from "../../(partials)/settings-group";
import EnvRow from "./env-row";
type Props = { type Props = {
scope: EnvScope; scope: EnvScope;
overview: EnvsOverviewPayload; overview: EnvsOverviewPayload;
onChanged: (overview: EnvsOverviewPayload, msg: string) => void; onChanged: (data: EnvsMutationResult, msg: string) => void;
onError: (msg: string) => void; onError: (msg: string) => void;
}; };
@@ -1,14 +1,14 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root"; 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 EnvScopeSelect from "../(partials)/env-scope-select";
import EnvsGroup from "../(partials)/envs-group";
import KnownEnvsGroup from "../(partials)/known-envs-group"; import KnownEnvsGroup from "../(partials)/known-envs-group";
import scopeValue, { envInScope } from "../(utils)/scope-value"; import scopeValue, { envInScope } from "../(utils)/scope-value";
/** /**
* Pick where (general, a workspace, a computer), then that scope's envs. * Pick where (general, a workspace, a bot or a computer), then that scope's
* General also lists the app settings. Every change saves at once and * envs. General also lists the app settings. Every change saves at once and
* refreshes the page data. * refreshes the page data.
*/ */
export default function EnvironmentSettingsSection() { export default function EnvironmentSettingsSection() {
@@ -26,8 +26,8 @@ export default function EnvironmentSettingsSection() {
const scope = const scope =
overview.scopes.find((s) => scopeValue({ scope: s }) == selected) || overview.scopes[0]!; overview.scopes.find((s) => scopeValue({ scope: s }) == selected) || overview.scopes[0]!;
const changed = (next: EnvsOverviewPayload, msg: string) => { const changed = (result: EnvsMutationResult, msg: string) => {
setOverview(next); if (result.overview) setOverview(result.overview);
setStatus({ success: true, msg }); setStatus({ success: true, msg });
setVersion((prev) => prev + 1); setVersion((prev) => prev + 1);
}; };
@@ -9,6 +9,7 @@ const env = (fields: Partial<PublicEnv>): PublicEnv => ({
paradigm: "general", paradigm: "general",
workspace_id: null, workspace_id: null,
computer_id: null, computer_id: null,
bot_id: null,
secret: true, secret: true,
expose_to_computers: true, expose_to_computers: true,
known: false, known: false,
@@ -19,6 +20,7 @@ describe("scopeValue", () => {
test("names each scope", () => { test("names each scope", () => {
expect(scopeValue({ scope: { paradigm: "general", id: null } })).toBe("general"); expect(scopeValue({ scope: { paradigm: "general", id: null } })).toBe("general");
expect(scopeValue({ scope: { paradigm: "computer", id: 4 } })).toBe("computer:4"); 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({ paradigm: "workspace", workspace_id: 3 }), scope })).toBe(false);
expect(envInScope({ env: env({}), 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"; 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"> }) { export default function scopeValue({ scope }: { scope: Pick<EnvScope, "paradigm" | "id"> }) {
return scope.paradigm == "general" ? "general" : `${scope.paradigm}:${scope.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 }) { export function envInScope({ env, scope }: { env: PublicEnv; scope: EnvScope }) {
if (env.paradigm != scope.paradigm) return false; if (env.paradigm != scope.paradigm) return false;
if (scope.paradigm == "workspace") return env.workspace_id == scope.id; 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; if (scope.paradigm == "computer") return env.computer_id == scope.id;
return true; return true;
} }
@@ -2,6 +2,7 @@ import { useContext, useState } from "react";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent"; import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import ScopedEnvsGroup from "../../../(partials)/envs/scoped-envs-group";
import ExtensionsGroup from "../../../(partials)/extensions-group"; import ExtensionsGroup from "../../../(partials)/extensions-group";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading"; import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell"; import SettingsShell from "../../../(partials)/settings-shell";
@@ -70,6 +71,7 @@ export default function WorkspaceOverview() {
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here." description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here."
initial={pageProps?.workspace_extensions} initial={pageProps?.workspace_extensions}
/> />
<ScopedEnvsGroup initial={pageProps?.workspace_envs} />
{harnesses?.length ? ( {harnesses?.length ? (
<> <>
<SettingsSectionHeading <SettingsSectionHeading
@@ -2,6 +2,9 @@ import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots"; import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-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 grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview"; import grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview"; import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces"; import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
@@ -45,6 +48,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
computers, computers,
workspace_harness_overview, workspace_harness_overview,
workspace_extensions, workspace_extensions,
workspace_envs,
] = await Promise.all([ ] = await Promise.all([
grabWorkspaceBots({ user_id, workspace_id }), grabWorkspaceBots({ user_id, workspace_id }),
grabBots({ user_id }), grabBots({ user_id }),
@@ -52,6 +56,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
grabUserComputers({ user_id }), grabUserComputers({ user_id }),
grabBotHarnessOverview({ workspace_id }).catch(() => null), grabBotHarnessOverview({ workspace_id }).catch(() => null),
grabWorkspaceExtensionsOverview({ user_id, workspace_id }), grabWorkspaceExtensionsOverview({ user_id, workspace_id }),
grabScopedEnvs({
paradigm: "workspace",
owner_id: workspace_id,
user_id,
can_manage: isPassThroughUser({
user_types: (await userAuth({ req: params.req })).user_types,
}),
}),
]); ]);
const crew = railBots({ bots: workspace_bots }); const crew = railBots({ bots: workspace_bots });
@@ -68,6 +80,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
computers, computers,
workspace_harness_overview, workspace_harness_overview,
workspace_extensions, workspace_extensions,
workspace_envs,
}, },
}; };
}; };
+32 -7
View File
@@ -3,9 +3,10 @@ import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user"; import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import deleteEnv from "@/src/functions/backend/db/envs/delete-env"; import deleteEnv from "@/src/functions/backend/db/envs/delete-env";
import grabEnvsOverview from "@/src/functions/backend/db/envs/grab-envs-overview"; import grabEnvsOverview from "@/src/functions/backend/db/envs/grab-envs-overview";
import grabScopedEnvs from "@/src/functions/backend/db/envs/grab-scoped-envs";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger"; import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { EnvIdRequestSchema } from "@/src/schemas/env-schema"; import { EnvIdRequestSchema } from "@/src/schemas/env-schema";
import type { EnvsOverviewPayload } from "@/src/types"; import type { EnvsMutationResult } from "@/src/types";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
@@ -15,10 +16,12 @@ import type {
} from "@moduletrace/bunext/types"; } from "@moduletrace/bunext/types";
/** /**
* Super admin only: removes an env. * Super admin only: removes an env. Answers with the fresh page data, the
* Environment overview or, from a bot's or workspace's settings page
* (`scoped`), just that scope's envs.
*/ */
export const handler: BunextAPIRouteHandler< export const handler: BunextAPIRouteHandler<
APIResponseObject<EnvsOverviewPayload> APIResponseObject<EnvsMutationResult>
> = async ({ req, body }) => { > = async ({ req, body }) => {
try { try {
if (req.method != "POST") { if (req.method != "POST") {
@@ -31,11 +34,14 @@ export const handler: BunextAPIRouteHandler<
throw new AppError(`Only a super admin can change the environment.`); throw new AppError(`Only a super admin can change the environment.`);
} }
const { env_id } = parseWithSchema({ schema: EnvIdRequestSchema, data: body }); const { env_id, scoped } = parseWithSchema({
schema: EnvIdRequestSchema,
data: body,
});
const env = ( const env = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({ await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
sql: `SELECT key, paradigm FROM envs WHERE id = ? LIMIT 1`, sql: `SELECT key, paradigm, workspace_id, bot_id FROM envs WHERE id = ? LIMIT 1`,
values: [env_id], values: [env_id],
}) })
).payload?.[0]; ).payload?.[0];
@@ -49,14 +55,33 @@ export const handler: BunextAPIRouteHandler<
category: "system", category: "system",
user_id: user.id, user_id: user.id,
plain_text: `User #${user.id} removed ${env.key} (${env.paradigm}).`, plain_text: `User #${user.id} removed ${env.key} (${env.paradigm}).`,
metadata: { env_id, key: env.key }, metadata: { env_id, key: env.key, paradigm: env.paradigm },
ip_address: grabRequestIP(req), ip_address: grabRequestIP(req),
}); });
if (scoped && (env.paradigm == "bot" || env.paradigm == "workspace")) {
const owner_id = env.paradigm == "bot" ? env.bot_id : env.workspace_id;
if (owner_id) {
return {
success: true,
msg: `Removed ${env.key}.`,
singleRes: {
scoped: await grabScopedEnvs({
paradigm: env.paradigm,
owner_id,
user_id: Number(user.id),
can_manage: true,
}),
},
};
}
}
return { return {
success: true, success: true,
msg: `Removed ${env.key}.`, msg: `Removed ${env.key}.`,
singleRes: await grabEnvsOverview({ user_id: Number(user.id) }), singleRes: { overview: await grabEnvsOverview({ user_id: Number(user.id) }) },
}; };
} catch (error: unknown) { } catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) }; return { success: false, msg: safeErrorMessage({ error }) };
+55 -6
View File
@@ -1,9 +1,10 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user"; import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabEnvsOverview from "@/src/functions/backend/db/envs/grab-envs-overview"; import grabEnvsOverview from "@/src/functions/backend/db/envs/grab-envs-overview";
import grabScopedEnvs from "@/src/functions/backend/db/envs/grab-scoped-envs";
import saveEnv from "@/src/functions/backend/db/envs/save-env"; import saveEnv from "@/src/functions/backend/db/envs/save-env";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger"; import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SaveEnvRequestSchema } from "@/src/schemas/env-schema"; import { SaveEnvRequestSchema, type SaveEnvRequest } from "@/src/schemas/env-schema";
import type { EnvsOverviewPayload } from "@/src/types"; import type { EnvsMutationResult } from "@/src/types";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
@@ -14,9 +15,12 @@ import type {
/** /**
* Super admin only: adds or edits an env. Logged by key, never by value. * Super admin only: adds or edits an env. Logged by key, never by value.
*
* Answers with the fresh page data: the Environment overview or, from a
* bot's or workspace's settings page (`scoped`), just that scope's envs.
*/ */
export const handler: BunextAPIRouteHandler< export const handler: BunextAPIRouteHandler<
APIResponseObject<EnvsOverviewPayload> APIResponseObject<EnvsMutationResult>
> = async ({ req, body }) => { > = async ({ req, body }) => {
try { try {
if (req.method != "POST") { if (req.method != "POST") {
@@ -33,21 +37,66 @@ export const handler: BunextAPIRouteHandler<
await saveEnv({ ...env, user_id: Number(user.id) }); await saveEnv({ ...env, user_id: Number(user.id) });
const scope_id =
env.paradigm == "workspace"
? env.workspace_id
: env.paradigm == "bot"
? env.bot_id
: env.paradigm == "computer"
? env.computer_id
: null;
await logEvent({ await logEvent({
title: env.env_id ? "Env changed" : "Env added", title: env.env_id ? "Env changed" : "Env added",
category: "system", category: "system",
user_id: user.id, user_id: user.id,
plain_text: `User #${user.id} ${env.env_id ? "changed" : "added"} ${env.key} (${env.paradigm}${env.workspace_id ? ` #${env.workspace_id}` : env.computer_id ? ` #${env.computer_id}` : ""}).`, plain_text: `User #${user.id} ${env.env_id ? "changed" : "added"} ${env.key} (${env.paradigm}${scope_id ? ` #${scope_id}` : ""}).`,
metadata: { key: env.key, paradigm: env.paradigm, workspace_id: env.workspace_id, computer_id: env.computer_id }, metadata: {
key: env.key,
paradigm: env.paradigm,
workspace_id: env.workspace_id,
computer_id: env.computer_id,
bot_id: env.bot_id,
},
ip_address: grabRequestIP(req), ip_address: grabRequestIP(req),
}); });
return { return {
success: true, success: true,
msg: `Saved ${env.key.trim()}.`, msg: `Saved ${env.key.trim()}.`,
singleRes: await grabEnvsOverview({ user_id: Number(user.id) }), singleRes: await mutationResult({ env, scoped: env.scoped, user_id: Number(user.id) }),
}; };
} catch (error: unknown) { } catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) }; return { success: false, msg: safeErrorMessage({ error }) };
} }
}; };
/**
* What the page is showing after the change.
*/
async function mutationResult({
env,
scoped,
user_id,
}: {
env: SaveEnvRequest;
scoped?: boolean;
user_id: number;
}): Promise<EnvsMutationResult> {
if (scoped && (env.paradigm == "bot" || env.paradigm == "workspace")) {
const owner_id = env.paradigm == "bot" ? env.bot_id : env.workspace_id;
if (owner_id) {
return {
scoped: await grabScopedEnvs({
paradigm: env.paradigm,
owner_id,
user_id,
can_manage: true,
}),
};
}
}
return { overview: await grabEnvsOverview({ user_id }) };
}
+10
View File
@@ -12,12 +12,22 @@ export const SaveEnvRequestSchema = z.object({
paradigm: z.enum(EnvParadigms.map((p) => p.value) as [EnvParadigm, ...EnvParadigm[]]), paradigm: z.enum(EnvParadigms.map((p) => p.value) as [EnvParadigm, ...EnvParadigm[]]),
workspace_id: z.coerce.number().int().positive().nullable().optional(), workspace_id: z.coerce.number().int().positive().nullable().optional(),
computer_id: z.coerce.number().int().positive().nullable().optional(), computer_id: z.coerce.number().int().positive().nullable().optional(),
bot_id: z.coerce.number().int().positive().nullable().optional(),
secret: z.boolean(), secret: z.boolean(),
expose_to_computers: z.boolean(), expose_to_computers: z.boolean(),
/**
* Set when the form was a bot's or workspace's settings page, which
* keeps only that scope's envs rather than the whole overview.
*/
scoped: z.boolean().optional(),
}); });
export const EnvIdRequestSchema = z.object({ export const EnvIdRequestSchema = z.object({
env_id: z.coerce.number().int().positive(), env_id: z.coerce.number().int().positive(),
/**
* A bot's or workspace's settings page, as `SaveEnvRequestSchema`'s.
*/
scoped: z.boolean().optional(),
}); });
export type SaveEnvRequest = z.infer<typeof SaveEnvRequestSchema>; export type SaveEnvRequest = z.infer<typeof SaveEnvRequestSchema>;
+28 -2
View File
@@ -459,6 +459,7 @@ export type PublicEnv = {
paradigm: EnvParadigm; paradigm: EnvParadigm;
workspace_id: number | null; workspace_id: number | null;
computer_id: number | null; computer_id: number | null;
bot_id: number | null;
secret: boolean; secret: boolean;
expose_to_computers: boolean; expose_to_computers: boolean;
value?: string; value?: string;
@@ -469,8 +470,8 @@ export type PublicEnv = {
}; };
/** /**
* Somewhere envs can be set: general, or one of the user's workspaces or * Somewhere envs can be set: general, or one of the user's workspaces,
* computers. * computers or bots.
*/ */
export type EnvScope = { export type EnvScope = {
paradigm: EnvParadigm; paradigm: EnvParadigm;
@@ -478,6 +479,25 @@ export type EnvScope = {
title: string; title: string;
}; };
/**
* One scope's envs, as a bot's or workspace's settings page sees them: the
* envs set there, and whether the viewer may change them.
*/
export type ScopedEnvsPayload = {
scope: EnvScope;
envs: PublicEnv[];
can_manage: boolean;
};
/**
* What saving or removing an env answers with: the Environment page's whole
* overview, or the one bot's or workspace's envs the settings pages show.
*/
export type EnvsMutationResult = {
overview?: EnvsOverviewPayload;
scoped?: ScopedEnvsPayload | null;
};
/** /**
* An app setting on the Environment page: whether it's saved, or comes * An app setting on the Environment page: whether it's saved, or comes
* from the server's environment instead. * from the server's environment instead.
@@ -982,6 +1002,12 @@ export type PagePropsType = {
*/ */
bot_text_model?: BotTextModel | null; bot_text_model?: BotTextModel | null;
workspace_extensions?: BotExtensionsPayload | null; workspace_extensions?: BotExtensionsPayload | null;
/**
* The bot's own envs, or a workspace's (its settings page). Only a
* super admin gets them: envs are theirs to set.
*/
bot_envs?: ScopedEnvsPayload | null;
workspace_envs?: ScopedEnvsPayload | null;
envs?: { envs?: {
GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_ID?: string;
R2_PUBLIC_DOMAIN?: string; R2_PUBLIC_DOMAIN?: string;